New UX, webhook securing, atomic repo toggling etc.

Secure Github webhooks (Fixes #18)
* Use a unique random secret for webhooks
* Validate payload signature when receiving webhook

Make enabling + disabling a repo more robust
* store repository state instead of enabled flag in DB
* atomic toggle UI button (Fixes #17)

New UX for managing repos
* group repos by owner
* look and feel according to UI spec

General improvements
* only request user's repos via Github API once per session
* fix issue with cljs code figwheel reload
* simplify app-db structure
This commit is contained in:
Teemu Patja 2017-02-18 11:07:51 +02:00
parent 6e646280c2
commit d35b794ca4
No known key found for this signature in database
GPG Key ID: F5B7035E6580FD4C
21 changed files with 482 additions and 240 deletions

View File

@ -7,7 +7,7 @@
(figwheel/watch-and-reload
:websocket-url "ws://localhost:3449/figwheel-ws"
:on-jsload core/mount-components)
:on-jsload core/on-js-load)
(devtools/install!)

View File

@ -37,8 +37,9 @@
[tentacles "0.5.1"]
[re-frisk "0.3.2"]
[bk/ring-gzip "0.2.1"]
;;
]
[crypto-random "1.2.0"]
[crypto-equality "1.0.0"]
[cheshire "5.7.0"]]
:min-lein-version "2.0.0"
:source-paths ["src/clj" "src/cljc"]

View File

@ -0,0 +1,15 @@
CREATE TABLE "public"."repo_state" (
"id" int,
"description" character varying(32) NOT NULL,
PRIMARY KEY ("id")
);
INSERT INTO "public"."repo_state"("id", "description") VALUES(0, 'Not enabled');
INSERT INTO "public"."repo_state"("id", "description") VALUES(1, 'Creating hook');
INSERT INTO "public"."repo_state"("id", "description") VALUES(2, 'Enabled');
INSERT INTO "public"."repo_state"("id", "description") VALUES(-1, 'Failed to create hook');
ALTER TABLE "public"."repositories" DROP COLUMN "enabled";
ALTER TABLE "public"."repositories"
ADD COLUMN "state" int NOT NULL DEFAULT 0;

View File

@ -0,0 +1,2 @@
ALTER TABLE "public"."repositories"
ADD COLUMN "hook_secret" character varying(64);

View File

@ -50,38 +50,58 @@ WHERE r.repo_id = :repo_id;
-- Repositories --------------------------------------------------------------------
-- :name toggle-repository! :<! :1
-- :doc toggles 'enabled' flag of a given repository
-- :name set-repo-state! :<! :1
-- :doc sets repository to given state
UPDATE repositories
SET enabled = NOT enabled
SET state = :state
WHERE repo_id = :repo_id
RETURNING repo_id, login, repo, enabled, hook_id;
RETURNING repo_id, login, repo, state, hook_id;
-- :name get-repo :? :1
-- :doc retrieve a repository given login and repo-name
SELECT *
FROM repositories
WHERE login = :login
AND repo = :repo;
-- :name create-repository! :<! :1
-- :doc creates repository if not exists
INSERT INTO repositories (repo_id, user_id, login, repo, enabled)
INSERT INTO repositories (repo_id, user_id, login, repo, state)
SELECT
:repo_id,
:user_id,
:login,
:repo,
:enabled
:state
WHERE NOT exists(SELECT 1
FROM repositories
WHERE repo_id = :repo_id)
RETURNING repo_id, user_id, login, repo, enabled;
RETURNING repo_id, user_id, login, repo, state;
-- :name get-enabled-repositories :? :*
-- :doc returns enabled repositories for a given login
SELECT repo_id
FROM repositories
WHERE user_id = :user_id AND enabled = TRUE;
WHERE user_id = :user_id
AND state = 2;
-- :name update-hook-id :! :n
-- :doc updates hook_id of a specified repository
-- :name update-repo-generic :! :n
/* :require [clojure.string :as string]
[hugsql.parameters :refer [identifier-param-quote]] */
UPDATE repositories
SET hook_id = :hook_id
WHERE repo_id = :repo_id;
SET
/*~
(string/join ","
(for [[field _] (:updates params)]
(str (identifier-param-quote (name field) options)
" = :v:updates." (name field))))
~*/
where repo_id = :repo_id;
-- Issues --------------------------------------------------------------------------

View File

@ -16,17 +16,14 @@
(some #(= label-name (:name %)) labels)))
(defn add-bounty-for-issue [repo-map issue]
(defn add-bounty-for-issue [repo repo-id login issue]
(log/debug "add-bounty-for-issue" issue)
(let [{issue-id :id
issue-number :number
issue-title :title} issue
{repo :repo
repo-id :repo_id
user :login} repo-map
created-issue (issues/create repo-id issue-id issue-number issue-title)
repo-owner (:address (users/get-repo-owner repo-id))]
(log/info (format "Issue %s/%s/%s labeled as bounty" user repo issue-number))
(log/info (format "Issue %s/%s/%s labeled as bounty" login repo issue-number))
(if (= 1 created-issue)
(let [transaction-hash (eth/deploy-contract repo-owner)]
(log/info "Contract deployed, transaction-hash:" transaction-hash )
@ -34,13 +31,11 @@
(log/debug "Issue already exists in DB, ignoring"))))
(defn add-bounties-for-existing-issues [repo-map]
(let [{repo :repo
user :login} repo-map
issues (github/get-issues user repo)
(defn add-bounties-for-existing-issues [repo repo-id login]
(let [issues (github/get-issues login repo)
bounty-issues (filter has-bounty-label? issues)]
(log/debug bounty-issues)
(log/debug "adding bounties for"
(count bounty-issues) " existing issues")
(doall
(map (partial add-bounty-for-issue repo-map) bounty-issues))))
(map (partial add-bounty-for-issue repo repo-id login) bounty-issues))))

View File

@ -1,21 +1,20 @@
(ns commiteth.db.repositories
(:require [commiteth.db.core :refer [*db*] :as db]
[clojure.java.jdbc :as jdbc]
[clojure.set :refer [rename-keys]]))
(defn toggle
"Toggles specified repository"
[repo-id]
(jdbc/with-db-connection [con-db *db*]
(db/toggle-repository! con-db {:repo_id repo-id})))
[clojure.set :refer [rename-keys]]
[clojure.string :as str]))
(defn create
"Creates repository"
"Creates repository or returns existing one."
[repo]
(jdbc/with-db-connection [con-db *db*]
(or
(db/create-repository! con-db (-> repo
(rename-keys {:id :repo_id :name :repo})
(merge {:enabled true})))))
(rename-keys {:id :repo_id
:name :repo})
(merge {:state 0})))
(db/get-repo {:repo (:name repo)
:login (:login repo)}))))
(defn get-enabled
"Lists enabled repositories ids for a given login"
@ -25,8 +24,16 @@
(db/get-enabled-repositories con-db {:user_id user-id}))
(mapcat vals)))
(defn update-hook-id
"Updates github webhook id for a given repository"
[repo-id hook-id]
(defn update-repo
[repo-id updates]
(jdbc/with-db-connection [con-db *db*]
(db/update-hook-id con-db {:repo_id repo-id :hook_id hook-id})))
(db/update-repo-generic con-db {:repo_id repo-id
:updates updates})))
(defn get-repo
"Get a repo from DB given it's full name (owner/repo-name)"
[full-name]
(let [[login repo-name] (str/split full-name #"/")]
(jdbc/with-db-connection [con-db *db*]
(db/get-repo {:login login :repo repo-name}))))

View File

@ -87,7 +87,6 @@
owner2 (format-param owner)
data (str contract-code owner1 owner2)
value (format "0x%x" 1)]
(println data)
(send-transaction (eth-account) nil value {:data data})))
(defn- format-call-params

View File

@ -41,7 +41,7 @@
:redirect_uri (redirect-uri)
:state state}}))
(defn- auth-params
(defn auth-params
[token]
{:oauth-token token
:client-id (client-id)
@ -64,19 +64,22 @@
:permissions
:private])
(def login-field [:owner :login])
(defn list-repos
"List all repos managed by the given user."
(defn get-user-repos
"List all repos managed by the given user. Returns map of repository
maps grouped by owner. Owner is an organization name or the user's
own login."
[token]
(let [all-repos-with-admin-access
(->>
(map #(merge
{:login (get-in % login-field)}
{:owner-login (get-in % [:owner :login])}
{:owner-type (get-in % [:owner :type])}
(select-keys % repo-fields))
(repos/repos (merge (auth-params token) {:type "all"
:all-pages true})))
(filter #(not (:fork %)))
(filter #(-> % :permissions :admin))))
(filter #(-> % :permissions :admin)))]
(group-by :owner-login all-repos-with-admin-access)))
(defn get-user
[token]
@ -91,12 +94,13 @@
:email)))
(defn add-webhook
[full-repo token]
[full-repo token secret]
(log/debug "adding webhook" full-repo token)
; TODO: use secret key in config param
(let [[user repo] (str/split full-repo #"/")]
(repos/create-hook user repo "web"
{:url (str (server-address) "/webhook")
:secret secret
:content_type "json"}
(merge (auth-params token)
{:events ["issues", "issue_comment", "pull_request"]

View File

@ -23,9 +23,7 @@
(wrap-routes middleware/wrap-csrf)
(wrap-routes middleware/wrap-formats))
#'redirect-routes
(-> #'webhook-routes
(wrap-routes wrap-json-params)
(wrap-routes wrap-keyword-params))
#'webhook-routes
#'service-routes
#'qr-routes
(route/not-found

View File

@ -11,7 +11,8 @@
[commiteth.bounties :as bounties]
[commiteth.github.core :as github]
[clojure.tools.logging :as log]
[commiteth.eth.core :as eth]))
[commiteth.eth.core :as eth]
[crypto.random :as random]))
(defn access-error [_ _]
(unauthorized {:error "unauthorized"}))
@ -28,6 +29,65 @@
[_ binding acc]
(update-in acc [:letks] into [binding `(:identity ~'+compojure-api-request+)]))
(defn enable-repo [repo-id repo login token]
(log/debug "enable-repo" repo-id repo)
(let [hook-secret (random/base64 32)]
(try
(repositories/update-repo repo-id {:state 1
:hook_secret hook-secret})
(let [created-hook (github/add-webhook repo token hook-secret)]
(log/debug "Created webhook:" created-hook)
(github/create-label repo token)
(repositories/update-repo repo-id {:state 2
:hook_id (:id created-hook)})
(bounties/add-bounties-for-existing-issues repo repo-id login))
(catch Exception e
(log/info "exception when creating webhook" (.getMessage e))
(repositories/update-repo repo-id {:state -1})))))
(defn disable-repo [repo-id repo hook-id token]
(log/debug "disable-repo" repo-id repo)
(do
(github/remove-webhook repo hook-id token)
(repositories/update-repo repo-id {:hook_secret ""
:state 0
:hook_id nil})))
(defn handle-toggle-repo [user params]
(log/debug "handle-toggle-repo" user params)
(let [{token :token
login :login
user-id :id} user
{repo-id :id
repo :full_name} params
db-item (repositories/create (merge params {:user_id user-id
:login login}))
is-enabled (= 2 (:state db-item))]
(if is-enabled
(disable-repo repo-id repo (:hook_id db-item) token)
(enable-repo repo-id repo login token))
(merge
{:enabled (not is-enabled)}
(select-keys params [:id :full_name]))))
(defn in? [coll elem]
(some #(= elem %) coll))
(defn handle-get-user-repos [user]
(log/debug "handle-get-user-repos")
(let [github-repos (github/get-user-repos (:token user))
enabled-repos (vec (repositories/get-enabled (:id user)))
repo-enabled? (fn [repo] (in? enabled-repos (:id repo)))
update-enabled (fn [repo] (assoc repo :enabled (repo-enabled? repo)))]
(into {}
(map (fn [[group repos]] {group
(map update-enabled repos)})
github-repos))))
(defapi service-routes
{:swagger {:ui "/swagger-ui"
:spec "/swagger.json"
@ -57,10 +117,7 @@
(GET "/repositories" []
:auth-rules authenticated?
:current-user user
(ok {:repositories (->> (github/list-repos (:token user))
(map #(select-keys %
[:id :html_url
:name :full_name :description])))}))
(ok {:repositories (handle-get-user-repos user)}))
(GET "/enabled-repositories" []
:auth-rules authenticated?
:current-user user
@ -86,20 +143,4 @@
(POST "/repository/toggle" {:keys [params]}
:auth-rules authenticated?
:current-user user
(ok (let [{repo-id :id
repo :full_name} params
{token :token
login :login
user-id :id} user
result (or
(repositories/create (merge params {:user_id user-id}))
(repositories/toggle repo-id))]
(if (:enabled result)
(let [created-hook (github/add-webhook repo token)]
(log/debug "Created webhook:" created-hook)
(future
(github/create-label repo token)
(repositories/update-hook-id repo-id (:id created-hook))
(bounties/add-bounties-for-existing-issues result)))
(github/remove-webhook repo (:hook_id result) token))
result))))))
(ok (handle-toggle-repo user params))))))

View File

@ -1,15 +1,19 @@
(ns commiteth.routes.webhooks
(:require [compojure.core :refer [defroutes POST]]
[commiteth.github.core :as github]
[commiteth.db.pull-requests :as pull-requests]
[commiteth.db.issues :as issues]
[commiteth.db.users :as users]
(:require [cheshire.core :as json]
[clojure.string :as str :refer [join]]
[clojure.tools.logging :as log]
[commiteth.bounties :as bounties]
[ring.util.http-response :refer [ok]]
[clojure.string :refer [join]]
[clojure.tools.logging :as log])
(:import [java.lang Integer]))
[commiteth.db
[issues :as issues]
[pull-requests :as pull-requests]
[repositories :as repos]
[users :as users]]
[commiteth.github.core :as github]
[commiteth.util.digest :refer [hex-hmac-sha1]]
[compojure.core :refer [defroutes POST]]
[crypto.equality :as crypto]
[ring.util.http-response :refer [ok]])
(:import java.lang.Integer))
(defn find-issue-event
[events type owner]
@ -125,15 +129,28 @@
(handle-issue-closed webhook-payload)))
(ok (str webhook-payload)))
(defn handle-pull-request
[pull-request]
(when (= "closed" (:action pull-request))
(handle-pull-request-closed pull-request))
(ok (str pull-request)))
(defn validate-secret [webhook-payload raw-payload github-signature]
(let [full-name (get-in webhook-payload [:repository :full_name])
repo (repos/get-repo full-name)
secret (:hook_secret repo)
signature (str "sha1=" (hex-hmac-sha1 secret raw-payload))]
(crypto/eq? signature github-signature)))
(defroutes webhook-routes
(POST "/webhook" {:keys [params headers]}
(POST "/webhook" {:keys [headers body]}
(let [raw-payload (slurp body)
payload (json/parse-string raw-payload true)]
(if (validate-secret payload raw-payload (get headers "x-hub-signature"))
(case (get headers "x-github-event")
"issues" (handle-issue params)
"pull_request" (handle-pull-request params)
(ok))))
"issues" (handle-issue payload)
"pull_request" (handle-pull-request payload)
(ok))))))

View File

@ -0,0 +1,6 @@
(ns commiteth.bounties
(:require [re-frame.core :as rf]))
(defn bounties-page []
(fn []
[:div "Bounties view not implemented"]))

View File

@ -10,6 +10,8 @@
[commiteth.handlers]
[commiteth.subscriptions]
[commiteth.activity :refer [activity-page]]
[commiteth.repos :refer [repos-page]]
[commiteth.bounties :refer [bounties-page]]
[commiteth.manage :refer [manage-page]]
[commiteth.issues :refer [issues-page]]
[commiteth.common :refer [input checkbox]]
@ -19,7 +21,7 @@
[re-frisk.core :refer [enable-re-frisk!]])
(:import goog.History))
(defn error-pane
#_(defn error-pane
[]
(let [error (rf/subscribe [:error])]
(fn []
@ -31,12 +33,12 @@
:on-click #(rf/dispatch [:clear-error])}
(str @error)]))))
(defn save-address
#_(defn save-address
[user-id address]
(fn [_]
(rf/dispatch [:save-user-address user-id address])))
(defn address-settings []
#_(defn address-settings []
(let [user (rf/subscribe [:user])
user-id (:id @user)
address (rf/subscribe [:get-in [:user :address]])]
@ -92,8 +94,8 @@
(fn []
(let [tabs (apply conj [[:activity "Activity"]]
(when @user
[[:issues "Bounties"]
[:manage "Repositories"]]))]
[[:repos "Repositories"]
[:bounties "Bounties"]]))]
(into [:div.ui.attached.tabular.menu.tiny]
(for [[page caption] tabs]
(let [props {:class (str "ui item"
@ -122,16 +124,17 @@
(def pages
{:activity #'activity-page
:issues #'issues-page
:manage #'manage-page})
:repos #'repos-page
:bounties #'bounties-page})
(defn page []
(fn []
[:div.ui.pusher
[page-header]
[error-pane]
[:div.ui.vertical.segment
[(pages @(rf/subscribe [:page]))]]]))
;; [error-pane]
[:div.ui.vertical.segment.foo
[:div.page-content
[(pages @(rf/subscribe [:page]))]]]]))
(secretary/set-config! :prefix "#")
@ -140,7 +143,7 @@
(secretary/defroute "/manage" []
(if js/user
(rf/dispatch [:set-active-page :manage])
(rf/dispatch [:set-active-page :repos])
(secretary/dispatch! "/")))
(defn hook-browser-navigation! []
@ -154,12 +157,18 @@
(defn mount-components []
(r/render [#'page] (.getElementById js/document "app")))
(defonce active-user (r/atom nil))
(defn load-user []
(when-let [login js/user]
(if-let [login js/user]
(when-not (= login @active-user)
(println "active user changed, loading user data")
(reset! active-user login)
(rf/dispatch [:set-active-user
{:login login
:id (js/parseInt js/userId)
:token js/token}])))
:token js/token}]))
(reset! active-user nil)))
(defn load-issues []
(rf/dispatch [:load-bounties]))
@ -168,7 +177,13 @@
(load-issues)
(load-user))
(js/setInterval load-data 60000)
(defonce timer-id (r/atom nil))
(defn on-js-load []
(when-not (nil? @timer-id)
(js/clearInterval @timer-id))
(reset! timer-id (js/setInterval load-data 60000))
(mount-components))
(defn init! []
(rf/dispatch-sync [:initialize-db])
@ -177,4 +192,4 @@
(load-interceptors!)
(hook-browser-navigation!)
(load-data)
(mount-components))
(on-js-load))

View File

@ -1,11 +1,10 @@
(ns commiteth.db)
(def default-db
{:page :issues
{:page :activity
:user nil
:repos-loading? false
:repos []
:enabled-repos {}
:repos {}
:all-bounties []
:owner-bounties []
:error nil

View File

@ -6,10 +6,12 @@
(reg-fx
:http
(fn [{:keys [method url on-success params]}]
(fn [{:keys [method url on-success on-error finally params]}]
(method url
{:headers {"Accept" "application/transit+json"}
:handler on-success
:error-handler on-error
:finally finally
:params params})))
(reg-event-db
@ -56,7 +58,6 @@
{:db (assoc db :user user)
:dispatch-n [[:load-user-profile]
[:load-user-repos]
[:load-enabled-repos]
[:load-owner-bounties]]}))
(reg-event-fx
@ -130,29 +131,45 @@
:url "/api/user/repositories"
:on-success #(dispatch [:set-user-repos (:repositories %)])}}))
(reg-event-db
:set-enabled-repos
(fn [db [_ repos]]
(assoc db :enabled-repos (zipmap repos (repeat true)))))
(reg-event-fx
:load-enabled-repos
(fn [{:keys [db]} [_]]
{:db db
:http {:method GET
:url "/api/user/enabled-repositories"
:on-success #(dispatch [:set-enabled-repos %])}}))
(defn update-repo-state [all-repos full-name data]
(let [[owner repo-name] (js->clj (.split full-name "/"))]
(println "update-repo-busy-state" owner repo-name)
(update all-repos
owner
(fn [repos] (map (fn [repo] (if (= (:name repo) repo-name)
(assoc repo
:busy? (:busy? data)
:enabled (:enabled data))
repo))
repos)))))
(reg-event-fx
:toggle-repo
(fn [{:keys [db]} [_ repo]]
(println "toggle-repo" repo)
{:db db
(println repo)
{:db (assoc db :repos (update-repo-state (:repos db) (:full_name repo) {:busy? true
:enabled (:enabled repo)}))
:http {:method POST
:url "/api/user/repository/toggle"
:on-success #(println %)
:on-success #(dispatch [:repo-toggle-success %])
;; TODO :on-error #(dispatch [:repo-toggle-error %])
:finally #(println "finally" %)
:params (select-keys repo [:id :login :full_name :name])}}))
(reg-event-db
:repo-toggle-success
(fn [db [_ repo]]
(println "repo-toggle-success" repo)
(assoc db :repos (update-repo-state (:repos db)
(:full_name repo)
{:busy? false
:enabled (:enabled repo)} ))))
(reg-event-fx
:save-user-address
(fn [{:keys [db]} [_ user-id address]]

View File

@ -4,6 +4,10 @@
[commiteth.issues :refer [issues-list-table issue-url]]
[clojure.set :refer [rename-keys]]))
(defn repository-row [repo]
(let [{repo-id :id
url :html_url

View File

@ -0,0 +1,60 @@
(ns commiteth.repos
(:require [re-frame.core :as rf]))
(defn repo-toggle-button [enabled busy on-click]
(let [add-busy-styles (fn [x] (conj x (when busy {:class (str "busy loading")})))
button (if enabled
[:div.ui.button.small.repo-added-button (add-busy-styles {})
[:i.icon.check]
"Added"]
[:div.ui.button.small
(add-busy-styles {:on-click on-click})
"Add"])]
[:div.ui.two.column.container
button
(when enabled
[:a.ui.item.remove-link {:on-click on-click} "Remove"])]))
(defn repo-card [repo]
[:div.ui.card
[:div.content
[:div.repo-label (:full_name repo)]
[:div.repo-description (:description repo)]]
[:div.repo-button-container
[repo-toggle-button
(:enabled repo)
(:busy? repo)
#(rf/dispatch [:toggle-repo repo])]]])
(defn repo-group-title [group login]
[:h3
(if (= group login)
"Personal repositories"
group)])
(defn repos-list []
(let [repos (rf/subscribe [:repos])
user (rf/subscribe [:user])
repo-groups (keys @repos)]
(fn []
(into [:div.ui.container]
(for [[group group-repos]
(map (fn [group] [group (get @repos group)]) repo-groups)]
[:div [repo-group-title group (:login @user)]
(into [:div.ui.cards]
(map repo-card group-repos))])))))
(defn repos-page []
(let [repos-loading? (rf/subscribe [:repos-loading?])]
(fn []
(if @repos-loading?
[:div.ui.container
[:p]
[:div.ui.active.dimmer
[:div.ui.loader]]]
[repos-list]))))

View File

@ -5,11 +5,22 @@
font-weight: normal;
}
@font-face {
font-family: 'postgrotesk-medium';
src: url('/fonts/PostGrotesk-Medium.woff') format('woff');
font-weight: normal;
}
.ui.button {
background-color: #b0f1ee;
white-space: nowrap;
padding: 1em 1.2em 1em;
}
.ui.small.button {
font-size: 15px!important;
}
.commiteth-header {
background-color: #2f3f44!important;
@ -27,7 +38,6 @@
}
.ui.header {
//font-weight: normal;
color: #fff;
}
@ -62,13 +72,17 @@ span.dropdown.icon {
font-family: 'postgrotesk';
}
.ui.tabular {
.ui.attached.tabular {
background-color: #2f3f44;
border-bottom: none;
.ui.item {
padding: 1.5em;
padding: 1em;
color: #fff;
opacity: 0.98;
cursor: pointer;
font-family: 'postgrotesk';
font-size: 16px;
font-weight: normal;
}
.ui.item.active {
border-radius: .8rem .8rem 0 0!important;
@ -79,24 +93,52 @@ span.dropdown.icon {
}
}
.repo-label {
color: #1bb5c1;
font-weight: bold;
font-family: 'postgrotesk-medium';
font-size: 16px;
}
.repo-description {
color: #474951;
font-size: 15px;
font-weight: normal;
line-height: 24px;
margin-top: 16px;
margin-bottom: 16px;
}
.repo-button-container {
display: inline-block;
vertical-align: bottom;
padding: 0em 1em 0em;
font-family: 'postgrotesk-medium';
font-size: 15px;
a {
padding: 1em;
font-size: 15px;
}
}
.page-content {
margin-top: 2em;
}
.ui.button.repo-added-button {
color: white;
background-color: #61deb0;
cursor: default;
}
.ui.cards>.card {
border: #e7e7e7 solid 0.1em;
box-shadow: none;
border-radius: 0.3em;
padding: 0.8em 1em 1.1em;
}
.ui.segment {
// margin: 2.5em;
// line-height: 3em;
border: #e7e7e7 solid 0.1em;
box-shadow: none;
border-radius: 0.3em;