From ba76c05ceca6a7879678873f360cdaf575f0f493 Mon Sep 17 00:00:00 2001 From: Max Magorsch Date: Tue, 8 Dec 2020 01:21:04 +0000 Subject: Initial version Signed-off-by: Max Magorsch --- .gitignore | 4 + .gitlab-ci.yml | 31 +++ Dockerfile | 14 ++ Dockerfile.dev | 6 + assets/.keep | 0 assets/application.css | 40 ++++ assets/application.js | 13 ++ assets/clipboard.min.js | 7 + bin/.keep | 0 docker-compose.override.yml | 47 ++++ docker-compose.yml | 35 +++ go.mod | 16 ++ go.sum | 455 ++++++++++++++++++++++++++++++++++++ gogentoo.go | 66 ++++++ pkg/app/handler/admin/admin.go | 21 ++ pkg/app/handler/admin/utils.go | 60 +++++ pkg/app/handler/auth/handlers.go | 101 ++++++++ pkg/app/handler/auth/init.go | 47 ++++ pkg/app/handler/auth/user.go | 35 +++ pkg/app/handler/index/index.go | 38 +++ pkg/app/handler/links/create.go | 66 ++++++ pkg/app/handler/links/delete.go | 41 ++++ pkg/app/handler/links/show.go | 19 ++ pkg/app/handler/links/utils.go | 157 +++++++++++++ pkg/app/serve.go | 74 ++++++ pkg/config/config.go | 75 ++++++ pkg/database/connection.go | 68 ++++++ pkg/logger/loggers.go | 39 ++++ pkg/models/link.go | 13 ++ pkg/models/projects.go | 25 ++ pkg/models/user.go | 61 +++++ web/templates/admin/show.tmpl | 121 ++++++++++ web/templates/layout/footer.tmpl | 51 ++++ web/templates/layout/head.tmpl | 12 + web/templates/layout/header.tmpl | 6 + web/templates/layout/sitetitle.tmpl | 37 +++ web/templates/layout/tyriannav.tmpl | 34 +++ web/templates/links/create.tmpl | 81 +++++++ web/templates/links/created.tmpl | 52 +++++ web/templates/links/show.tmpl | 173 ++++++++++++++ 40 files changed, 2241 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 Dockerfile.dev create mode 100644 assets/.keep create mode 100644 assets/application.css create mode 100644 assets/application.js create mode 100644 assets/clipboard.min.js create mode 100644 bin/.keep create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gogentoo.go create mode 100644 pkg/app/handler/admin/admin.go create mode 100644 pkg/app/handler/admin/utils.go create mode 100644 pkg/app/handler/auth/handlers.go create mode 100644 pkg/app/handler/auth/init.go create mode 100644 pkg/app/handler/auth/user.go create mode 100644 pkg/app/handler/index/index.go create mode 100644 pkg/app/handler/links/create.go create mode 100644 pkg/app/handler/links/delete.go create mode 100644 pkg/app/handler/links/show.go create mode 100644 pkg/app/handler/links/utils.go create mode 100644 pkg/app/serve.go create mode 100644 pkg/config/config.go create mode 100644 pkg/database/connection.go create mode 100644 pkg/logger/loggers.go create mode 100644 pkg/models/link.go create mode 100644 pkg/models/projects.go create mode 100644 pkg/models/user.go create mode 100644 web/templates/admin/show.tmpl create mode 100644 web/templates/layout/footer.tmpl create mode 100644 web/templates/layout/head.tmpl create mode 100644 web/templates/layout/header.tmpl create mode 100644 web/templates/layout/sitetitle.tmpl create mode 100644 web/templates/layout/tyriannav.tmpl create mode 100644 web/templates/links/create.tmpl create mode 100644 web/templates/links/created.tmpl create mode 100644 web/templates/links/show.tmpl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd43d2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin +.idea +node_modules +assets diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..d815c1d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,31 @@ +stages: + - build + +build: + stage: build + except: + - tags + variables: + IMAGE_TAG: $CI_REGISTRY_IMAGE/$CI_COMMIT_BRANCH:$CI_COMMIT_SHA + LATEST_IMAGE_TAG: $CI_REGISTRY_IMAGE/$CI_COMMIT_BRANCH:latest + script: + - echo $IMAGE_TAG + - echo $LATEST_IMAGE_TAG + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin + - docker build --no-cache -t $IMAGE_TAG -t $LATEST_IMAGE_TAG . + - docker push $LATEST_IMAGE_TAG + - docker push $IMAGE_TAG + +build-tag: + stage: build + only: + - tags + variables: + IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG + script: + - echo $IMAGE_TAG + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" "$CI_REGISTRY" --password-stdin + - docker build -t $IMAGE_TAG . + - docker push $IMAGE_TAG diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..92f4fed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.14.0 AS builder +WORKDIR /go/src/go-gentoo +COPY . /go/src/go-gentoo +RUN go get github.com/go-pg/pg/v9 +RUN go get github.com/mcuadros/go-version +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o bin . + +FROM scratch +WORKDIR /go/src/go-gentoo +COPY --from=builder /go/src/go-gentoo/assets /go/src/go-gentoo/assets +COPY --from=builder /go/src/go-gentoo/bin /go/src/go-gentoo/bin +COPY --from=builder /go/src/go-gentoo/pkg /go/src/go-gentoo/pkg +COPY --from=builder /go/src/go-gentoo/web /go/src/go-gentoo/web +ENTRYPOINT ["/go/src/go-gentoo/bin/go-gentoo", "--serve"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..2e346d6 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,6 @@ +FROM golang:1.14.0 +RUN apt update && apt install -y ca-certificates ntp ntpdate +WORKDIR /go/src/go-gentoo +COPY . /go/src/go-gentoo + +CMD tail -f /dev/null diff --git a/assets/.keep b/assets/.keep new file mode 100644 index 0000000..e69de29 diff --git a/assets/application.css b/assets/application.css new file mode 100644 index 0000000..204e3c8 --- /dev/null +++ b/assets/application.css @@ -0,0 +1,40 @@ +.typeahead-field input, +.typeahead-select { + display:block; + width:100%; + font-size:13px; + color:#555; + background:0 0; + border-radius:2px 0 0 2px; + -webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075); + box-shadow:inset 0 1px 1px rgba(0,0,0,0.075); + -webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s; + -o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s; + transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s +} +.typeahead-field input { + -webkit-appearance:none; + background:0 0 +} +.typeahead-field input:last-child, +.typeahead-hint { + background:#fff +} + +.separator { + display: flex; + align-items: center; + text-align: center; + color: grey; +} +.separator::before, .separator::after { + content: ''; + flex: 1; + border-bottom: 1px solid lightgrey; +} +.separator::before { + margin-right: .25em; +} +.separator::after { + margin-left: .25em; +} diff --git a/assets/application.js b/assets/application.js new file mode 100644 index 0000000..7dd3951 --- /dev/null +++ b/assets/application.js @@ -0,0 +1,13 @@ +function togglePrefix(){ + if(document.getElementById('prefix').value == ''){ + document.getElementById('token').value = ""; + document.getElementById('token').disabled = true; + document.getElementById('token').placeholder = "Only available for prefix != '/'"; + document.getElementById('index').value = "no"; + document.getElementById('index').disabled = true; + }else{ + document.getElementById('token').disabled = false; + document.getElementById('token').placeholder = "Automatically generated if empty"; + document.getElementById('index').disabled = false; + } +} diff --git a/assets/clipboard.min.js b/assets/clipboard.min.js new file mode 100644 index 0000000..b9ed143 --- /dev/null +++ b/assets/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.6 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return o={},r.m=n=[function(t,e){t.exports=function(t){var e;if("SELECT"===t.nodeName)t.focus(),e=t.value;else if("INPUT"===t.nodeName||"TEXTAREA"===t.nodeName){var n=t.hasAttribute("readonly");n||t.setAttribute("readonly",""),t.select(),t.setSelectionRange(0,t.value.length),n||t.removeAttribute("readonly"),e=t.value}else{t.hasAttribute("contenteditable")&&t.focus();var o=window.getSelection(),r=document.createRange();r.selectNodeContents(t),o.removeAllRanges(),o.addRange(r),e=o.toString()}return e}},function(t,e){function n(){}n.prototype={on:function(t,e,n){var o=this.e||(this.e={});return(o[t]||(o[t]=[])).push({fn:e,ctx:n}),this},once:function(t,e,n){var o=this;function r(){o.off(t,r),e.apply(n,arguments)}return r._=e,this.on(t,r,n)},emit:function(t){for(var e=[].slice.call(arguments,1),n=((this.e||(this.e={}))[t]||[]).slice(),o=0,r=n.length;o options per entry +// - change expire +// - delete +// (- add new) diff --git a/pkg/app/handler/admin/admin.go b/pkg/app/handler/admin/admin.go new file mode 100644 index 0000000..299f0b7 --- /dev/null +++ b/pkg/app/handler/admin/admin.go @@ -0,0 +1,21 @@ +// Used to show the landing page of the application + +package admin + +import ( + "html/template" + "net/http" +) + +// Show renders a template to show the landing page of the application +func Show(w http.ResponseWriter, r *http.Request) { + + templates := template.Must( + template.Must( + template.New("Show"). + Funcs(getFuncMap()). + ParseGlob("web/templates/layout/*.tmpl")). + ParseGlob("web/templates/admin/*.tmpl")) + + templates.ExecuteTemplate(w, "show.tmpl", getPageData(w, r)) +} diff --git a/pkg/app/handler/admin/utils.go b/pkg/app/handler/admin/utils.go new file mode 100644 index 0000000..67bb0d6 --- /dev/null +++ b/pkg/app/handler/admin/utils.go @@ -0,0 +1,60 @@ +// miscellaneous utility functions used for the landing page of the application + +package admin + +import ( + "crypto/md5" + "fmt" + "go-gentoo/pkg/app/handler/auth" + "go-gentoo/pkg/database" + "go-gentoo/pkg/models" + "html/template" + "net/http" + "strings" +) + +func getPageData(w http.ResponseWriter, r *http.Request) interface{} { + user := auth.GetUser(w, r) + return struct { + Tab string + User *models.User + UserLinks []models.Link + }{ + Tab: "admin", + User: user, + UserLinks: getAllLinks(), + } +} + +func getFuncMap() template.FuncMap { + return template.FuncMap{ + "gravatar": emailToGravater, + "replaceAll": strings.ReplaceAll, + "getPrefixList": getPrefixList, + } +} + +func emailToGravater(email string) string { + return "https://www.gravatar.com/avatar/" + fmt.Sprintf("%x", md5.Sum([]byte(email))) +} + +func getAllLinks() []models.Link { + var links []models.Link + database.DBCon.Model(&links). + Select() + return links +} + +func getPrefixList(links []models.Link) []string { + prefixMap := make(map[string]bool) + var prefixList []string + for _, link := range links { + if link.Prefix != "" { + prefixMap[link.Prefix] = true + } + } + for key, _ := range prefixMap { + prefixList = append(prefixList, key) + } + return prefixList +} diff --git a/pkg/app/handler/auth/handlers.go b/pkg/app/handler/auth/handlers.go new file mode 100644 index 0000000..8e79959 --- /dev/null +++ b/pkg/app/handler/auth/handlers.go @@ -0,0 +1,101 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "go-gentoo/pkg/config" + "go-gentoo/pkg/models" + "golang.org/x/oauth2" + "net/http" + "net/url" +) + +func Login(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 16) + rand.Read(b) + + state := base64.URLEncoding.EncodeToString(b) + + session, _ := CookieStore.Get(r, config.SessionStoreKey()) + session.Values["state"] = state + session.Save(r, w) + + url := Oauth2Config.AuthCodeURL(state) + http.Redirect(w, r, url, http.StatusFound) +} + +func Callback(w http.ResponseWriter, r *http.Request) { + session, err := CookieStore.Get(r, config.SessionStoreKey()) + + if err != nil { + http.Error(w, "state did not match", http.StatusBadRequest) + return + } + + if r.URL.Query().Get("state") != session.Values["state"] { + http.Error(w, "state did not match", http.StatusBadRequest) + return + } + + oauth2Token, err := Oauth2Config.Exchange(Ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError) + return + } + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError) + return + } + idToken, err := Verifier.Verify(Ctx, rawIDToken) + if err != nil { + http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError) + return + } + + resp := struct { + OAuth2Token *oauth2.Token + IDTokenClaims *json.RawMessage // ID Token payload is just JSON. + }{oauth2Token, new(json.RawMessage)} + + if err := idToken.Claims(&resp.IDTokenClaims); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + type Response struct { + GivenName string `json:"given_name"` + Username string `json:"preferred_username"` + Email string `json:"email"` + } + + var keycloakResponse Response + err = json.Unmarshal(*resp.IDTokenClaims, &keycloakResponse) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + session.Values["idToken"] = rawIDToken + session.Values["user"] = models.User{ + Email: keycloakResponse.Email, + RealName: keycloakResponse.GivenName, + UserName: keycloakResponse.Username, + Projects: nil, + } + err = session.Save(r, w) + + http.Redirect(w, r, "/", http.StatusFound) +} + +// http://www.gorillatoolkit.org/pkg/sessions#CookieStore.MaxAge +func Logout(w http.ResponseWriter, r *http.Request) { + session, err := CookieStore.Get(r, config.SessionStoreKey()) + if err != nil { + return + } + session.Options.MaxAge = -1 + session.Save(r, w) + http.Redirect(w, r, config.OIDConfigURL()+"/protocol/openid-connect/logout?redirect_uri="+url.QueryEscape(config.ApplicationURL()), 302) +} diff --git a/pkg/app/handler/auth/init.go b/pkg/app/handler/auth/init.go new file mode 100644 index 0000000..e97e997 --- /dev/null +++ b/pkg/app/handler/auth/init.go @@ -0,0 +1,47 @@ +package auth + +import ( + "context" + "encoding/gob" + "github.com/coreos/go-oidc" + "github.com/gorilla/sessions" + "go-gentoo/pkg/config" + "go-gentoo/pkg/models" + "golang.org/x/oauth2" +) + +var ( + Oauth2Config oauth2.Config + Verifier *oidc.IDTokenVerifier + Ctx context.Context + CookieStore *sessions.CookieStore +) + +func Init() { + gob.Register(&models.User{}) + + Ctx = context.Background() + provider, err := oidc.NewProvider(Ctx, config.OIDConfigURL()) + if err != nil { + panic(err) + } + + CookieStore = sessions.NewCookieStore([]byte(config.SessionSecret())) + + // Configure an OpenID Connect aware OAuth2 client. + Oauth2Config = oauth2.Config{ + ClientID: config.OIDClientID(), + ClientSecret: config.OIDClientSecret(), + RedirectURL: config.ApplicationURL() + "/auth/callback", + // Discovery returns the OAuth2 endpoints. + Endpoint: provider.Endpoint(), + // "openid" is a required scope for OpenID Connect flows. + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + oidcConfig := &oidc.Config{ + ClientID: config.OIDClientID(), + } + Verifier = provider.Verifier(oidcConfig) + +} diff --git a/pkg/app/handler/auth/user.go b/pkg/app/handler/auth/user.go new file mode 100644 index 0000000..9761d80 --- /dev/null +++ b/pkg/app/handler/auth/user.go @@ -0,0 +1,35 @@ +package auth + +import ( + "go-gentoo/pkg/config" + "go-gentoo/pkg/models" + "net/http" +) + +func IsValidUser(w http.ResponseWriter, r *http.Request) bool { + session, err := CookieStore.Get(r, config.SessionStoreKey()) + + if err != nil { + return false + } + + if token, ok := session.Values["idToken"].(string); ok { + _, err = Verifier.Verify(Ctx, token) + return err == nil + } + + return false +} + +func GetUser(w http.ResponseWriter, r *http.Request) *models.User { + session, err := CookieStore.Get(r, config.SessionStoreKey()) + if err != nil { + return nil + } + user := session.Values["user"].(*models.User) + err = user.ComputeProjects() + if err != nil { + return nil + } + return user +} diff --git a/pkg/app/handler/index/index.go b/pkg/app/handler/index/index.go new file mode 100644 index 0000000..e31d32c --- /dev/null +++ b/pkg/app/handler/index/index.go @@ -0,0 +1,38 @@ +// Used to show the landing page of the application + +package index + +import ( + "go-gentoo/pkg/database" + "go-gentoo/pkg/models" + "net/http" +) + +// Show renders a template to show the landing page of the application +func Handle(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + redirect(w, r) + return + } + http.Redirect(w, r, "/links/create", http.StatusFound) +} + +func redirect(w http.ResponseWriter, r *http.Request) { + links := getLink(r.URL.Path) + if len(links) != 1 { + http.Error(w, "Not found", http.StatusNotFound) + } else { + link := links[0] + link.Hits++ + database.DBCon.Model(&link).WherePK().Update() + http.Redirect(w, r, links[0].TargetLink, http.StatusFound) + } +} + +func getLink(shortlink string) []models.Link { + var links []models.Link + database.DBCon.Model(&links). + Where("short_link = ?", shortlink). + Select() + return links +} diff --git a/pkg/app/handler/links/create.go b/pkg/app/handler/links/create.go new file mode 100644 index 0000000..3d88f59 --- /dev/null +++ b/pkg/app/handler/links/create.go @@ -0,0 +1,66 @@ +package links + +import ( + "go-gentoo/pkg/app/handler/auth" + "html/template" + "net/http" +) + +// Show renders a template to show the landing page of the application +func Create(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + showForm(w, r) + case http.MethodPost: + createLink(w, r) + } +} + +func showForm(w http.ResponseWriter, r *http.Request) { + + templates := template.Must( + template.Must( + template.New("Show"). + Funcs(getFuncMap()). + ParseGlob("web/templates/layout/*.tmpl")). + ParseGlob("web/templates/links/create.tmpl")) + + templates.ExecuteTemplate(w, "create.tmpl", getPageData(w, r, "create")) +} + +func createLink(w http.ResponseWriter, r *http.Request) { + user := auth.GetUser(w, r) + + r.ParseForm() + prefix := r.Form.Get("prefix") + index := r.Form.Get("index") + token := r.Form.Get("token") + target := r.Form.Get("target") + + if !user.IsAdmin() && prefix == "" { + token = "" + } + + if isValidPrefix(user, prefix) && isValidToken(prefix) && isValidToken(token) && isValidTarget(target) { + shortlink, ok := createShortURL(prefix, token, target, user.Email, index == "yes", 0) + if ok { + renderCreatedTemplate(w, r, shortlink, target) + return + } else { + http.Error(w, "Could not create shortened URL", http.StatusInternalServerError) + } + } else { + http.Error(w, "Params are not valid", http.StatusBadRequest) + } +} + +func renderCreatedTemplate(w http.ResponseWriter, r *http.Request, shortlink, target string) { + templates := template.Must( + template.Must( + template.New("Show"). + Funcs(getFuncMap()). + ParseGlob("web/templates/layout/*.tmpl")). + ParseGlob("web/templates/links/created.tmpl")) + + templates.ExecuteTemplate(w, "created.tmpl", getCreatePageData(w, r, shortlink, target)) +} diff --git a/pkg/app/handler/links/delete.go b/pkg/app/handler/links/delete.go new file mode 100644 index 0000000..ee4bce4 --- /dev/null +++ b/pkg/app/handler/links/delete.go @@ -0,0 +1,41 @@ +package links + +import ( + "go-gentoo/pkg/app/handler/auth" + "go-gentoo/pkg/database" + "go-gentoo/pkg/models" + "net/http" +) + +func Delete(w http.ResponseWriter, r *http.Request) { + user := auth.GetUser(w, r) + + r.ParseForm() + prefix := r.Form.Get("prefix") + token := r.Form.Get("token") + from := r.Form.Get("from") + var shortlink string + if prefix != "" { + shortlink = "/" + prefix + "/" + token + } else { + shortlink = "/" + token + } + + links := getLink(shortlink) + + if len(links) != 1 { + http.Error(w, "Could not delete shortened URL", http.StatusInternalServerError) + return + } + + if user.IsAdmin() || links[0].UserEmail == user.Email || contains(user.Projects, links[0].Prefix) || links[0].Prefix == user.UserName { + link := new(models.Link) + _, err := database.DBCon.Model(link).Where("short_link = ?", shortlink).Delete() + if err != nil { + http.Error(w, "Could not delete shortened URL", http.StatusInternalServerError) + } else { + http.Redirect(w, r, from, http.StatusFound) + } + } + +} diff --git a/pkg/app/handler/links/show.go b/pkg/app/handler/links/show.go new file mode 100644 index 0000000..4e5c5c6 --- /dev/null +++ b/pkg/app/handler/links/show.go @@ -0,0 +1,19 @@ +package links + +import ( + "html/template" + "net/http" +) + +// Show renders a template to show the landing page of the application +func Show(w http.ResponseWriter, r *http.Request) { + + templates := template.Must( + template.Must( + template.New("Show"). + Funcs(getFuncMap()). + ParseGlob("web/templates/layout/*.tmpl")). + ParseGlob("web/templates/links/show.tmpl")) + + templates.ExecuteTemplate(w, "show.tmpl", getPageData(w, r, "show")) +} diff --git a/pkg/app/handler/links/utils.go b/pkg/app/handler/links/utils.go new file mode 100644 index 0000000..26afbbf --- /dev/null +++ b/pkg/app/handler/links/utils.go @@ -0,0 +1,157 @@ +package links + +import ( + "crypto/md5" + "fmt" + "github.com/catinello/base62" + "go-gentoo/pkg/app/handler/auth" + "go-gentoo/pkg/config" + "go-gentoo/pkg/database" + "go-gentoo/pkg/models" + "html/template" + "net/http" + "net/url" + "strings" +) + +const ( + MAX_TRY = 10 + MAX_TOKEN_LENGTH = 75 +) + +func getPageData(w http.ResponseWriter, r *http.Request, tab string) interface{} { + user := auth.GetUser(w, r) + return struct { + Tab string + User *models.User + UserLinks []models.Link + }{ + Tab: tab, + User: user, + UserLinks: getLinks(user), + } +} + +func getCreatePageData(w http.ResponseWriter, r *http.Request, shortlink, target string) interface{} { + return struct { + Tab string + User *models.User + ShortLink string + Target string + }{ + Tab: "new", + User: auth.GetUser(w, r), + ShortLink: config.ApplicationURL() + shortlink, + Target: target, + } +} + +func getFuncMap() template.FuncMap { + return template.FuncMap{ + "gravatar": emailToGravater, + "replaceAll": strings.ReplaceAll, + } +} + +func emailToGravater(email string) string { + return "https://www.gravatar.com/avatar/" + fmt.Sprintf("%x", md5.Sum([]byte(email))) +} + +func getLinks(user *models.User) []models.Link { + var links []models.Link + database.DBCon.Model(&links). + Where("user_email = '" + user.Email + "'"). + WhereOr(createQuery(user)). + Select() + return links +} + +func createQuery(user *models.User) string { + var queryParts []string + for _, project := range user.Projects { + queryParts = append(queryParts, "prefix = '"+project+"'") + } + return strings.Join(queryParts, " OR ") +} + +func isValidPrefix(user *models.User, prefix string) bool { + return prefixIsNotReserved(prefix) && (contains(user.Projects, prefix) || prefix == "" || prefix == user.UserName) +} + +func prefixIsNotReserved(prefix string) bool { + return prefix != "auth" && prefix != "assets" && prefix != "links" && prefix != "admin" +} + +func isValidToken(token string) bool { + for _, char := range token { + if !strings.Contains(config.ValidURLTokenChars(), strings.ToLower(string(char))) { + return false + } + } + return len(token) < MAX_TOKEN_LENGTH +} + +func isValidTarget(target string) bool { + _, err := url.ParseRequestURI(target) + return err == nil +} + +func getLatestId() int { + + var links []models.Link + database.DBCon.Model(&links). + Order("id DESC"). + Limit(1). + Select() + + if len(links) != 1 { + return 4000 + } + + return links[0].Id +} + +func createShortURL(prefix, token, target, username string, setIndex bool, try int) (string, bool) { + var shortlink string + id := getLatestId() + 1 + if token == "" && (!setIndex || prefix == "") { + token = base62.Encode(id) + } + if prefix != "" { + shortlink = "/" + prefix + "/" + token + } else { + shortlink = "/" + token + } + + if len(getLink(shortlink)) > 0 && try <= MAX_TRY { + return createShortURL(prefix, token, target, username, setIndex, try+1) + } + + err := database.DBCon.Insert(&models.Link{ + Id: id, + Prefix: prefix, + URLToken: token, + ShortLink: shortlink, + TargetLink: target, + UserEmail: username, + Hits: 0, + }) + return shortlink, err == nil +} + +func getLink(shortlink string) []models.Link { + var links []models.Link + database.DBCon.Model(&links). + Where("short_link = ?", shortlink). + Select() + return links +} + +func contains(list []string, value string) bool { + for _, v := range list { + if v == value { + return true + } + } + return false +} diff --git a/pkg/app/serve.go b/pkg/app/serve.go new file mode 100644 index 0000000..fc2cdc3 --- /dev/null +++ b/pkg/app/serve.go @@ -0,0 +1,74 @@ +package app + +import ( + "go-gentoo/pkg/app/handler/admin" + "go-gentoo/pkg/app/handler/auth" + "go-gentoo/pkg/app/handler/index" + "go-gentoo/pkg/app/handler/links" + "go-gentoo/pkg/config" + "go-gentoo/pkg/database" + "go-gentoo/pkg/logger" + "log" + "net/http" +) + +// Serve is used to serve the web application +func Serve() { + + database.Connect() + defer database.DBCon.Close() + + auth.Init() + setRoute("/auth/login", auth.Login) + setRoute("/auth/logout", auth.Logout) + setRoute("/auth/callback", auth.Callback) + + setProtectedRoute("/links/show", links.Show) + setProtectedRoute("/links/create", links.Create) + setProtectedRoute("/links/delete", links.Delete) + + setProtectedRoute("/admin/", admin.Show) + + setProtectedRoute("/", index.Handle) + + fs := http.StripPrefix("/assets/", http.FileServer(http.Dir("/go/src/go-gentoo/assets"))) + http.Handle("/assets/", fs) + + logger.Info.Println("Serving on port: " + config.Port()) + log.Fatal(http.ListenAndServe(":"+config.Port(), nil)) +} + +// define a route using the default middleware and the given handler +func setProtectedRoute(path string, handler http.HandlerFunc) { + http.HandleFunc(path, protectedMW(handler)) +} + +// mw is used as default middleware to set the default headers +func protectedMW(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if auth.IsValidUser(w, r) { + setDefaultHeaders(w) + handler(w, r) + } else { + http.Redirect(w, r, "/auth/login", 301) + } + } +} + +// define a route using the default middleware and the given handler +func setRoute(path string, handler http.HandlerFunc) { + http.HandleFunc(path, mw(handler)) +} + +// mw is used as default middleware to set the default headers +func mw(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + setDefaultHeaders(w) + handler(w, r) + } +} + +// setDefaultHeaders sets the default headers that apply for all pages +func setDefaultHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache") +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..261ff3a --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,75 @@ +package config + +import "os" + +func PostgresUser() string { + return getEnv("GO_GENTOO_POSTGRES_USER", "root") +} + +func PostgresPass() string { + return getEnv("GO_GENTOO_POSTGRES_PASS", "root") +} + +func PostgresDb() string { + return getEnv("GO_GENTOO_POSTGRES_DB", "gogentoo") +} + +func PostgresHost() string { + return getEnv("GO_GENTOO_POSTGRES_HOST", "db") +} + +func PostgresPort() string { + return getEnv("GO_GENTOO_POSTGRES_PORT", "5432") +} + +func Port() string { + return getEnv("GO_GENTOO_PORT", "5000") +} + +func CacheControl() string { + return getEnv("GO_GENTOO_CACHE_CONTROL", "max-age=300") +} + +func Debug() string { + return getEnv("GO_GENTOO_DEBUG", "false") +} + +func LogFile() string { + return getEnv("GO_GENTOO_LOG_FILE", "/var/log/go-gentoo/errors.log") +} + +func OIDConfigURL() string { + return getEnv("GO_GENTOO_OID_CONFIG_URL", "https://sso.gentoo.org/auth/realms/gentoo") +} + +func OIDClientID() string { + return getEnv("GO_GENTOO_OID_CLIENT_ID", "demo-client") +} + +func OIDClientSecret() string { + return getEnv("GO_GENTOO_OID_CLIENT_SECRET", "00000000-0000-0000-0000-000000000000") +} + +func ApplicationURL() string { + return getEnv("GO_GENTOO_APPLICATION_URL", "https://go.gentoo.org") +} + +func SessionStoreKey() string { + return getEnv("GO_GENTOO_SESSION_STORE_KEY", "gentoo_sess") +} + +func SessionSecret() string { + return getEnv("GO_GENTOO_SESSION_SECRET", "123456789") +} + +func ValidURLTokenChars() string { + return getEnv("GO_GENTOO_VALID_URL_TOKEN_CHARS", "abcdefghijklmnopqrstuvwxyz1234567890-") +} + +func getEnv(key string, fallback string) string { + if os.Getenv(key) != "" { + return os.Getenv(key) + } else { + return fallback + } +} diff --git a/pkg/database/connection.go b/pkg/database/connection.go new file mode 100644 index 0000000..c5e7c7b --- /dev/null +++ b/pkg/database/connection.go @@ -0,0 +1,68 @@ +// Contains utility functions around the database + +package database + +import ( + "context" + "github.com/go-pg/pg/v9" + "github.com/go-pg/pg/v9/orm" + "go-gentoo/pkg/config" + "go-gentoo/pkg/logger" + "go-gentoo/pkg/models" + "log" +) + +// DBCon is the connection handle +// for the database +var ( + DBCon *pg.DB +) + +// CreateSchema creates the tables in the database +// in case they don't alreay exist +func CreateSchema() error { + for _, model := range []interface{}{(*models.Link)(nil)} { + + err := DBCon.CreateTable(model, &orm.CreateTableOptions{ + IfNotExists: true, + }) + if err != nil { + return err + } + + } + return nil +} + +type dbLogger struct{} + +func (d dbLogger) BeforeQuery(c context.Context, q *pg.QueryEvent) (context.Context, error) { + return c, nil +} + +// AfterQuery is used to log SQL queries +func (d dbLogger) AfterQuery(c context.Context, q *pg.QueryEvent) error { + logger.Debug.Println(q.FormattedQuery()) + return nil +} + +// Connect is used to connect to the database +// and turn on logging if desired +func Connect() { + DBCon = pg.Connect(&pg.Options{ + User: config.PostgresUser(), + Password: config.PostgresPass(), + Database: config.PostgresDb(), + Addr: config.PostgresHost() + ":" + config.PostgresPort(), + }) + + DBCon.AddQueryHook(dbLogger{}) + + err := CreateSchema() + if err != nil { + logger.Error.Println("ERROR: Could not create database schema") + logger.Error.Println(err) + log.Fatalln(err) + } + +} diff --git a/pkg/logger/loggers.go b/pkg/logger/loggers.go new file mode 100644 index 0000000..0ef51e9 --- /dev/null +++ b/pkg/logger/loggers.go @@ -0,0 +1,39 @@ +package logger + +import ( + "io" + "log" + "os" +) + +var ( + Debug *log.Logger + Info *log.Logger + Error *log.Logger +) + +func Init( + debugHandle io.Writer, + infoHandle io.Writer, + errorHandle io.Writer) { + + Debug = log.New(debugHandle, + "DEBUG: ", + log.Ldate|log.Ltime|log.Lshortfile) + + Info = log.New(infoHandle, + "INFO: ", + log.Ldate|log.Ltime|log.Lshortfile) + + Error = log.New(errorHandle, + "ERROR: ", + log.Ldate|log.Ltime|log.Lshortfile) +} + +func CreateLogFile(path string) *os.File { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Println(err) + } + return f +} diff --git a/pkg/models/link.go b/pkg/models/link.go new file mode 100644 index 0000000..34d96fa --- /dev/null +++ b/pkg/models/link.go @@ -0,0 +1,13 @@ +// Contains the model of the application data + +package models + +type Link struct { + Id int `pg:",pk"` + Prefix string + URLToken string + ShortLink string `pg:",unique"` + TargetLink string + UserEmail string + Hits int +} diff --git a/pkg/models/projects.go b/pkg/models/projects.go new file mode 100644 index 0000000..63b1c35 --- /dev/null +++ b/pkg/models/projects.go @@ -0,0 +1,25 @@ +package models + +import "encoding/xml" + +type ProjectList struct { + XMLName xml.Name `xml:"projects"` + Projects []Project `xml:"project"` +} + +type Project struct { + XMLName xml.Name `xml:"project" pg:"-"` + Email string `xml:"email" pg:",pk"` + Name string `xml:"name"` + Url string `xml:"url"` + Description string `xml:"description"` + Members []Member `xml:"member"` +} + +type Member struct { + XMLName xml.Name `xml:"member" json:"-" pg:"-"` + IsLead bool `xml:"is-lead,attr"` + Email string `xml:"email"` + Name string `xml:"name"` + Role string `xml:"role"` +} diff --git a/pkg/models/user.go b/pkg/models/user.go new file mode 100644 index 0000000..09dc247 --- /dev/null +++ b/pkg/models/user.go @@ -0,0 +1,61 @@ +// Contains the model of the application data + +package models + +import ( + "encoding/xml" + "io/ioutil" + "net/http" + "strings" +) + +type User struct { + Email string `pg:",pk"` + RealName string + UserName string + Projects []string +} + +func (u *User) IsAdmin() bool { + for _, project := range u.Projects { + if project == "infra" { + return true + } + } + return false +} + +func (u *User) ComputeProjects() error { + projects, err := parseProjectList() + + if err != nil { + return err + } + + for _, project := range projects.Projects { + for _, member := range project.Members { + if member.Email == u.Email { + abbreviation := strings.ReplaceAll(project.Email, "@gentoo.org", "") + u.Projects = append(u.Projects, abbreviation) + } + } + } + + return nil +} + +// parseQAReport gets the xml from qa-reports.gentoo.org and parses it +func parseProjectList() (ProjectList, error) { + resp, err := http.Get("https://api.gentoo.org/metastructure/projects.xml") + if err != nil { + return ProjectList{}, err + } + defer resp.Body.Close() + xmlData, err := ioutil.ReadAll(resp.Body) + if err != nil { + return ProjectList{}, err + } + var projectList ProjectList + xml.Unmarshal(xmlData, &projectList) + return projectList, err +} diff --git a/web/templates/admin/show.tmpl b/web/templates/admin/show.tmpl new file mode 100644 index 0000000..2dbbb78 --- /dev/null +++ b/web/templates/admin/show.tmpl @@ -0,0 +1,121 @@ + + +{{template "head" .}} + +{{template "header" .}} + +
+
+
+ +

/

+ + {{$empty := true}} + + + + + + + + + + + + + + + + + + + + {{$empty = true}} + {{range .UserLinks}} + {{if eq .Prefix ""}} + {{$empty = false}} + + + + + + + + {{end}} + {{end}} + {{if $empty}} + + + + {{end}} + +
Short URLTargetCreatorHitsAction
go.gentoo.org{{.ShortLink}}{{.TargetLink}}{{replaceAll .UserEmail "@gentoo.org" ""}}{{.Hits}} +
+ + + + +
+
No shortened URLs yet
+ + + {{range $index, $project := getPrefixList .UserLinks}} +

/{{$project}}/

+ + + + + + + + + + + + + + + + + + {{$empty = true}} + {{range $.UserLinks}} + {{if eq .Prefix $project}} + {{$empty = false}} + + + + + + + {{end}} + {{end}} + {{if $empty}} + + + + {{end}} + +
Short URLTargetCreatorAction
go.gentoo.org{{.ShortLink}}{{.TargetLink}}{{replaceAll .UserEmail "@gentoo.org" ""}} +
+ + + + +
+
No shortened URLs yet
+ {{end}} +
+
+
+ + +{{template "footer" .}} + + + + diff --git a/web/templates/layout/footer.tmpl b/web/templates/layout/footer.tmpl new file mode 100644 index 0000000..3b5efcf --- /dev/null +++ b/web/templates/layout/footer.tmpl @@ -0,0 +1,51 @@ +{{define "footer"}} +
+
+
+
+

Gentoo Packages Database

+
+
+
+
+
+
+
+
+
+
+

Questions or comments?

+ Please feel free to contact us. +
+
+
+
+ +
+
+ © 2001–2020 Gentoo Foundation, Inc.
+ + Gentoo is a trademark of the Gentoo Foundation, Inc. + The contents of this document, unless otherwise expressly stated, are licensed under the + CC-BY-SA-4.0 license. + The Gentoo Name and Logo Usage Guidelines apply. + +
+
+ Contact
+
+
+
+
+ + + + +{{end}} diff --git a/web/templates/layout/head.tmpl b/web/templates/layout/head.tmpl new file mode 100644 index 0000000..4ab796a --- /dev/null +++ b/web/templates/layout/head.tmpl @@ -0,0 +1,12 @@ +{{define "head"}} + + Gentoo URL Shortener + + + + + + + + +{{end}} diff --git a/web/templates/layout/header.tmpl b/web/templates/layout/header.tmpl new file mode 100644 index 0000000..571a58b --- /dev/null +++ b/web/templates/layout/header.tmpl @@ -0,0 +1,6 @@ +{{define "header"}} +
+ {{template "sitetitle"}} + {{template "tyrian-navbar" .}} +
+{{end}} diff --git a/web/templates/layout/sitetitle.tmpl b/web/templates/layout/sitetitle.tmpl new file mode 100644 index 0000000..bd07b34 --- /dev/null +++ b/web/templates/layout/sitetitle.tmpl @@ -0,0 +1,37 @@ +{{define "sitetitle"}} + +{{end}} diff --git a/web/templates/layout/tyriannav.tmpl b/web/templates/layout/tyriannav.tmpl new file mode 100644 index 0000000..6e1de17 --- /dev/null +++ b/web/templates/layout/tyriannav.tmpl @@ -0,0 +1,34 @@ +{{define "tyrian-navbar"}} + +{{end}} diff --git a/web/templates/links/create.tmpl b/web/templates/links/create.tmpl new file mode 100644 index 0000000..3644e08 --- /dev/null +++ b/web/templates/links/create.tmpl @@ -0,0 +1,81 @@ + + +{{template "head" .}} + +{{template "header" .}} + +
+
+
+ +
+

Welcome {{.User.UserName}}!
+ Shorten your next link

+ +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+ +
+
+
+ + +{{template "footer" .}} + + + + + diff --git a/web/templates/links/created.tmpl b/web/templates/links/created.tmpl new file mode 100644 index 0000000..e3f93a5 --- /dev/null +++ b/web/templates/links/created.tmpl @@ -0,0 +1,52 @@ + + +{{template "head" .}} + +{{template "header" .}} + +
+
+
+ +
+

Congratulations {{.User.UserName}}!
+ Your link has been shortened

+ +
+
+
+
+ + + +
+
+
+ +
+ +
+ +
+
+ +
+
+
+
+ +
+
+
+ +{{template "footer" .}} + + + + + + diff --git a/web/templates/links/show.tmpl b/web/templates/links/show.tmpl new file mode 100644 index 0000000..de961f5 --- /dev/null +++ b/web/templates/links/show.tmpl @@ -0,0 +1,173 @@ + + +{{template "head" .}} + +{{template "header" .}} + +
+
+
+ +

/

+ + {{$empty := true}} + + + + + + + + + + + + + + + + + + + + {{$empty = true}} + {{range .UserLinks}} + {{if eq .Prefix ""}} + {{$empty = false}} + + + + + + + + {{end}} + {{end}} + {{if $empty}} + + + + {{end}} + +
Short URLTargetCreatorHitsAction
go.gentoo.org{{.ShortLink}}{{.TargetLink}}{{replaceAll .UserEmail "@gentoo.org" ""}}{{.Hits}} +
+ + + + +
+
No shortened URLs yet
+ +

/{{.User.UserName}}/

+ + + + + + + + + + + + + + + + + + + + {{$empty = true}} + {{range .UserLinks}} + {{if eq .Prefix $.User.UserName}} + {{$empty = false}} + + + + + + + + {{end}} + {{end}} + {{if $empty}} + + + + {{end}} + +
Short URLTargetCreatorHitsAction
go.gentoo.org{{.ShortLink}}{{.TargetLink}}{{replaceAll .UserEmail "@gentoo.org" ""}}{{.Hits}} +
+ + + + +
+
No shortened URLs yet
+ +
Projects
+ + {{range $index, $project := .User.Projects}} +

/{{$project}}/

+ + + + + + + + + + + + + + + + + + + + {{$empty = true}} + {{range $.UserLinks}} + {{if eq .Prefix $project}} + {{$empty = false}} + + + + + + + + {{end}} + {{end}} + {{if $empty}} + + + + {{end}} + +
Short URLTargetCreatorHitsAction
go.gentoo.org{{.ShortLink}}{{.TargetLink}}{{replaceAll .UserEmail "@gentoo.org" ""}}{{.Hits}} +
+ + + + +
+
No shortened URLs yet
+ {{end}} +
+
+
+ +{{template "footer" .}} + + + -- cgit v1.2.3-65-gdbad