From 85e4df52a56e733d9c05a675672a0185830063df Mon Sep 17 00:00:00 2001 From: Tueem Date: Wed, 24 Jun 2026 00:16:21 +0200 Subject: [PATCH] feat(*): initial commit --- Dockerfile | 16 +++ assets.go | 6 + cmd/main.go | 23 +++- entrypoint.sh | 45 ++++++++ frontend/index.html | 0 go.mod | 8 +- go.sum | 12 ++ internal/auth/jwt.go | 54 +++++++++ internal/auth/oauth.go | 125 ++++++++++++++++++++ internal/auth/redirect.go | 22 ++++ internal/auth/routes.go | 125 ++++++++++++++++++++ internal/cache.go | 45 ++++++++ internal/database/database.go | 14 +++ internal/database/state.go | 87 ++++++++++++++ internal/database/vote.go | 201 +++++++++++++++++++++++++++++++++ internal/http/server.go | 46 ++++++++ internal/http/vote/result.go | 33 ++++++ internal/http/vote/vote.go | 127 +++++++++++++++++++++ internal/http/vote/votelock.go | 124 ++++++++++++++++++++ internal/util/rand.go | 15 +++ 20 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 Dockerfile create mode 100644 assets.go create mode 100644 entrypoint.sh create mode 100644 frontend/index.html create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/oauth.go create mode 100644 internal/auth/redirect.go create mode 100644 internal/auth/routes.go create mode 100644 internal/cache.go create mode 100644 internal/database/vote.go create mode 100644 internal/http/server.go create mode 100644 internal/http/vote/result.go create mode 100644 internal/http/vote/vote.go create mode 100644 internal/http/vote/votelock.go create mode 100644 internal/util/rand.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a47d6a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Build stage +FROM golang:latest AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -v -o outfit-voting-abi26 ./cmd/ + +# Final stage +FROM debian:bookworm-slim +WORKDIR /root/ +EXPOSE 4000 +COPY --from=builder /app/outfit-voting-abi26 /usr/local/bin/outfit-voting-abi26 +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..5e903b9 --- /dev/null +++ b/assets.go @@ -0,0 +1,6 @@ +package outfitvotingabi26 + +import "embed" + +//go:embed frontend +var frontendFS embed.FS diff --git a/cmd/main.go b/cmd/main.go index 0beabe2..3cf5947 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,10 +4,16 @@ import ( "flag" "github.com/lib/pq" + "tomatentum.net/outfit-voting-abi26/internal/auth" "tomatentum.net/outfit-voting-abi26/internal/database" + "tomatentum.net/outfit-voting-abi26/internal/http" ) var config pq.Config +var ( + oidcIssuer, oidcClientID, oidcClientSecret, oidcRedirectURI string + jwtSigningKey string +) func main() { handleflag() @@ -15,9 +21,15 @@ func main() { database.OpenDB(config) defer database.CloseDB() - for { - + auth.SetSigningKey(jwtSigningKey) + if err := auth.InitProvider(oidcIssuer, oidcClientID, oidcClientSecret, oidcRedirectURI); err != nil { + panic(err) } + + if err := http.Start(); err != nil { + panic(err) + } + } func handleflag() { @@ -28,5 +40,12 @@ func handleflag() { flag.StringVar(&config.User, "dbuser", "postgres", "Postgres user") flag.StringVar(&config.Password, "dbpass", "", "Postgres password") + flag.StringVar(&oidcIssuer, "oidcIssuer", "", "The OIDC Issuer") + flag.StringVar(&oidcClientID, "oidcClientID", "", "The OIDC Client ID") + flag.StringVar(&oidcClientSecret, "oidcClientSecret", "", "The OIDC Client Secret") + flag.StringVar(&oidcRedirectURI, "oidcRedirectURI", "", "The OIDC Redirect URI") + + flag.StringVar(&jwtSigningKey, "signingKey", "UNSAFE", "The JWT signingkey") + flag.Parse() } diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..637c6d1 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +CMD="outfit-voting-abi26" + +if [ -n "$DB_HOST" ]; then + CMD="$CMD --dbhost $DB_HOST" +fi + +if [ -n "$DB_PORT" ]; then + CMD="$CMD --dbport $DB_PORT" +fi + +if [ -n "$DB_NAME" ]; then + CMD="$CMD --dbname $DB_NAME" +fi + +if [ -n "$DB_USER" ]; then + CMD="$CMD --dbuser $DB_USER" +fi + +if [ -n "$DB_PASS" ]; then + CMD="$CMD --dbpass $DB_PASS" +fi + +if [ -n "$OIDC_ISSUER" ]; then + CMD="$CMD --oidcIssuer $OIDC_ISSUER" +fi + +if [ -n "$OIDC_CLIENTID" ]; then + CMD="$CMD --oidcClientID $OIDC_CLIENTID" +fi + +if [ -n "$OIDC_SECRET" ]; then + CMD="$CMD --oidcClientSecret $OIDC_SECRET" +fi + +if [ -n "$OIDC_REDIRECT" ]; then + CMD="$CMD --oidcRedirectURI $OIDC_REDIRECT" +fi + +if [ -n "$SIGNINGKEY" ]; then + CMD="$CMD --signingKey $SIGNINGKEY" +fi + +eval exec $CMD "$@" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index bf050a2..531aa66 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,10 @@ module tomatentum.net/outfit-voting-abi26 go 1.25.5 -require github.com/lib/pq v1.12.3 // indirect +require ( + github.com/coreos/go-oidc/v3 v3.19.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/lib/pq v1.12.3 // indirect + golang.org/x/oauth2 v0.36.0 // indirect +) diff --git a/go.sum b/go.sum index c7cd147..dba2de6 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,14 @@ +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coocood/freecache v1.2.7 h1:IDP0x1Yg8sgRmsSWzFyhaB+amYJpKS7v5QIXNHxXvM8= +github.com/coocood/freecache v1.2.7/go.mod h1:+Ga2+A5/0D6MMistGuoeKZaZucAGZ56u+fYKiY+xqNA= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..f657b2d --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,54 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var signingkey []byte + +type JWTClaims struct { + User string `json:"user"` + jwt.RegisteredClaims +} + +func SetSigningKey(key string) { + signingkey = []byte(key) +} + +func NewJWT(user string) (string, error) { + claims := JWTClaims{ + user, + jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), // Expires in 24 hours + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "outfit-voting-abi26", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + return token.SignedString(signingkey) +} + +func ValidateJWT(token string) (string, error) { + claims := &JWTClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return signingkey, nil + }) + + if err != nil { + return "", err + } + + claims, ok := parsed.Claims.(*JWTClaims) + + if !ok { + return "", jwt.ErrTokenInvalidClaims + } + + return claims.User, nil +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..905a669 --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "errors" + "log" + "net/http" + "slices" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "tomatentum.net/outfit-voting-abi26/internal/util" +) + +var ( + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + config oauth2.Config + ctx context.Context + authCookie string = "outfit-voting-abi26-auth" +) + +func InitProvider(issuer, id, secret, redirect string) error { + ctx = context.Background() + var err error + provider, err = oidc.NewProvider(ctx, issuer) + if err != nil { + return err + } + verifier = provider.Verifier(&oidc.Config{ + ClientID: id, + }) + + config = oauth2.Config{ + ClientID: id, + ClientSecret: secret, + Endpoint: provider.Endpoint(), + RedirectURL: redirect, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + return nil +} + +func GetToken(code string) (*oidc.IDToken, error) { + token, err := config.Exchange(ctx, code) + + if err != nil { + return &oidc.IDToken{}, err + } + + rawToken, ok := token.Extra("id_token").(string) + + if !ok { + return &oidc.IDToken{}, errors.New("id_token missing") + } + + return verifier.Verify(ctx, rawToken) +} + +func Redirect(w http.ResponseWriter, r *http.Request) { + state, nonce := setCookies(w, r) + AddRedirect(state, r.RequestURI) + + url := config.AuthCodeURL(state, oidc.Nonce(nonce)) + http.Redirect(w, r, url, http.StatusFound) +} + +func CheckState(r *http.Request) error { + cookie, err := r.Cookie("state") + if err != nil { + return errors.Join(err, errors.New("State not found")) + } + log.Printf("DEBUG: statecookie: %s, statequery: %s\n", cookie.Value, r.URL.Query().Get("state")) + if r.URL.Query().Get("state") != cookie.Value { + return errors.New("State did not match") + } + return nil +} + +func CheckNonce(r *http.Request, idtoken *oidc.IDToken) error { + cookie, err := r.Cookie("nonce") + if err != nil { + return errors.Join(err, errors.New("Nonce not found")) + } + if idtoken.Nonce != cookie.Value { + return errors.New("Nonce did not match") + } + return nil +} + +func CheckAudience(idtoken *oidc.IDToken) error { + if !slices.Contains(idtoken.Audience, config.ClientID) { + return errors.New("Audience does not match") + } + return nil +} + +func setCookies(w http.ResponseWriter, r *http.Request) (state, nonce string) { + state, err := util.RandString(16) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + nonce, err = util.RandString(16) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + setCallbackCookie(w, r, "state", state) + setCallbackCookie(w, r, "nonce", nonce) + + return state, nonce +} + +func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { + c := &http.Cookie{ + Name: name, + Value: value, + MaxAge: int(time.Hour.Seconds()), + Secure: r.TLS != nil, + HttpOnly: true, + } + http.SetCookie(w, c) +} diff --git a/internal/auth/redirect.go b/internal/auth/redirect.go new file mode 100644 index 0000000..15710a8 --- /dev/null +++ b/internal/auth/redirect.go @@ -0,0 +1,22 @@ +package auth + +import ( + "sync" +) + +var callbackRedirectMap map[string]string = make(map[string]string, 5) +var callbackRedirectMutex sync.Mutex + +func AddRedirect(state, url string) { + callbackRedirectMutex.Lock() + callbackRedirectMap[state] = url + callbackRedirectMutex.Unlock() +} + +func GetRedirect(state string) string { + callbackRedirectMutex.Lock() + redirect := callbackRedirectMap[state] + delete(callbackRedirectMap, state) + callbackRedirectMutex.Unlock() + return redirect +} diff --git a/internal/auth/routes.go b/internal/auth/routes.go new file mode 100644 index 0000000..b66d8a8 --- /dev/null +++ b/internal/auth/routes.go @@ -0,0 +1,125 @@ +package auth + +import ( + "log" + "net/http" + "net/url" + "strings" + "time" +) + +func IsAuth(r *http.Request) (bool, error) { + jwt, err := getAuthToken(r) + if err != nil { + return false, err + } + _, err = ValidateJWT(jwt) + if err != nil { + return false, err + } + return true, nil +} + +func LogoutEndpoint(w http.ResponseWriter, r *http.Request) { + setAuthCookie(w, r, "") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func AuthMiddleware(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwt, err := getAuthToken(r) + if err != nil { + log.Println("Error during token retrieval", err) + Redirect(w, r) + return + } + user, err := ValidateJWT(jwt) + if err != nil { + log.Println("Error during authorization", err) + Redirect(w, r) + return + } + log.Printf("%s: Authorized user %s", r.URL.Path, user) + w.Header().Add("user", user) + next.ServeHTTP(w, r) + }) +} + +func CallbackHandler(mux *http.ServeMux) { + + url, err := url.Parse(config.RedirectURL) + + if err != nil { + log.Fatalf("Invalid Redirect URL Submitted: %s\n", config.RedirectURL) + } + + mux.HandleFunc(url.Path, func(w http.ResponseWriter, r *http.Request) { + if err := CheckState(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + + idtoken, err := GetToken(code) + + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := CheckAudience(idtoken); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := CheckNonce(r, idtoken); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + jwt, err := NewJWT(idtoken.Subject) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + setAuthCookie(w, r, jwt) + + redirect := GetRedirect(r.URL.Query().Get("state")) + log.Printf("DEBUG: REDIRECT %s\n", redirect) + http.Redirect(w, r, redirect, http.StatusFound) + }) +} + +func setAuthCookie(w http.ResponseWriter, r *http.Request, jwt string) { + c := &http.Cookie{ + Name: authCookie, + Value: jwt, + MaxAge: int(time.Hour.Seconds()), + Secure: r.TLS != nil, + Path: "/", + HttpOnly: true, + } + http.SetCookie(w, c) +} + +func getAuthToken(r *http.Request) (string, error) { + header := strings.Split(r.Header.Get("Authorization"), " ") + if len(header) > 1 { + token := header[1] + if len(strings.TrimSpace(token)) > 0 { + log.Printf("Got Auth Header %s\n", header[1]) + + return token, nil + } + } + + cookie, err := r.Cookie(authCookie) + + if err != nil { + return "", err + } + return cookie.Value, nil +} diff --git a/internal/cache.go b/internal/cache.go new file mode 100644 index 0000000..82231b5 --- /dev/null +++ b/internal/cache.go @@ -0,0 +1,45 @@ +package internal + +import ( + "sync" + "time" +) + +type CacheItem[T any] struct { + item T + imutex sync.Mutex + expiry time.Time + TTLSeconds uint + FetchFunc func() (T, error) +} + +func (c *CacheItem[T]) Get() (T, error) { + c.imutex.Lock() + defer c.imutex.Unlock() + + if !c.expiry.After(time.Now()) { + c.expiry = time.Now().Add(time.Duration(c.TTLSeconds) * time.Second) + + item, err := c.FetchFunc() + if err != nil { + return item, err + } + + c.item = item + } + + return c.item, nil +} + +func (c *CacheItem[T]) Invalidate() { + c.imutex.Lock() + defer c.imutex.Unlock() + c.expiry = time.Now() +} + +func NewCache[T any](fetchfunc func() (T, error), ttl uint) CacheItem[T] { + return CacheItem[T]{ + TTLSeconds: ttl, + FetchFunc: fetchfunc, + } +} diff --git a/internal/database/database.go b/internal/database/database.go index 5a37928..68f4004 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "log" + "time" "github.com/lib/pq" ) @@ -17,11 +18,24 @@ func OpenDB(conf pq.Config) { } db = sql.OpenDB(connector) + db.SetMaxOpenConns(500) + db.SetMaxIdleConns(50) + db.SetConnMaxLifetime(time.Hour) + if err := db.Ping(); err != nil { panic(err) } log.Printf("Database opened to %s:%d, %s as %s!\n", conf.Host, conf.Port, conf.Database, conf.User) + + if err := initStateDB(); err != nil { + panic(err) + } + + if err := initVoteDB(); err != nil { + panic(err) + } + } func CloseDB() { diff --git a/internal/database/state.go b/internal/database/state.go index e69de29..9be05b1 100644 --- a/internal/database/state.go +++ b/internal/database/state.go @@ -0,0 +1,87 @@ +package database + +import ( + "strconv" + + "tomatentum.net/outfit-voting-abi26/internal" +) + +const INITSTATEDB string = "CREATE TABLE IF NOT EXISTS state(key varchar(255) PRIMARY KEY, value TEXT)" +const STATEINSERT string = "INSERT INTO state(key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING;" +const STATESET string = "UPDATE state SET value = $1 WHERE key = $2" +const STATEGET string = "SELECT value FROM state WHERE key = $1" + +const KEYVOTELOCKED string = "votinglocked" +const KEYRESULTLOCKED string = "resultlocked" + +var voteLocked internal.CacheItem[bool] = internal.NewCache(func() (bool, error) { + res := db.QueryRow(STATEGET, KEYVOTELOCKED) + + var valuestring string + + if err := res.Scan(&valuestring); err != nil { + return false, err + } + value, err := strconv.ParseBool(valuestring) + + if err != nil { + return false, err + } + return value, nil +}, 30) + +var resultLocked internal.CacheItem[bool] = internal.NewCache(func() (bool, error) { + res := db.QueryRow(STATEGET, KEYRESULTLOCKED) + + var valuestring string + + if err := res.Scan(&valuestring); err != nil { + return false, err + } + + value, err := strconv.ParseBool(valuestring) + + if err != nil { + return false, err + } + return value, nil +}, 30) + +func initStateDB() error { + _, err := db.Exec(INITSTATEDB) + + if err != nil { + return err + } + + _, err = db.Exec(STATEINSERT, KEYVOTELOCKED, "true") + _, err = db.Exec(STATEINSERT, KEYRESULTLOCKED, "true") + + if err != nil { + return err + } + return nil +} + +func setState(key string, value string) error { + _, err := db.Exec(STATESET, value, key) + return err +} + +func GetVoteLocked() (bool, error) { + return voteLocked.Get() +} + +func GetResultLocked() (bool, error) { + return resultLocked.Get() +} + +func SetVoteLocked(value bool) error { + voteLocked.Invalidate() + return setState(KEYVOTELOCKED, strconv.FormatBool(value)) +} + +func SetResultLocked(value bool) error { + resultLocked.Invalidate() + return setState(KEYRESULTLOCKED, strconv.FormatBool(value)) +} diff --git a/internal/database/vote.go b/internal/database/vote.go new file mode 100644 index 0000000..b7e334e --- /dev/null +++ b/internal/database/vote.go @@ -0,0 +1,201 @@ +package database + +import ( + "errors" + "fmt" + "strings" + + "tomatentum.net/outfit-voting-abi26/internal/util" +) + +const VALUESTEMPLATE string = "(%s, %s, %s, %d)" + +const INITVOTEDB string = "CREATE TABLE IF NOT EXISTS vote(id varchar(16) PRIMARY KEY, category TEXT NOT NULL, name TEXT NOT NULL, votes int NOT NULL)" +const INSERTVOTEENTRY string = "INSERT INTO vote(id, category, name, votes) VALUES($1, $2, $3, $4)" +const BULKINSERTVOTEENTRY string = "INSERT INTO vote(id, name, votes) VALUES" +const SETVOTE string = "UPDATE vote SET votes = $1 WHERE id = $2" +const INCVOTE string = "UPDATE vote SET votes = votes + 1 WHERE id = $1" +const DELVOTEENTRY string = "DELETE FROM vote WHERE id = $1" +const BULKDELVOTEENTRY string = "DELETE FROM vote WHERE key IN (%s)" +const GETVOTES string = "SELECT * FROM vote ORDER BY votes DESC" +const GETVOTEOPTIONS string = "SELECT id, name FROM vote" +const GETVOTE string = "SELECT * FROM vote WHERE id = $1" + +type VoteEntry struct { + VoteOption + Votes int +} + +type VoteOption struct { + ID string + Category string + Name string +} + +func initVoteDB() error { + _, err := db.Exec(INITVOTEDB) + return err +} + +func InsertVoteEntry(name string, category string) (VoteEntry, error) { + id, err := util.RandString(8) + + if err != nil { + return VoteEntry{}, err + } + _, err = db.Exec(INSERTVOTEENTRY, id, name, 0) + + if err != nil { + return VoteEntry{}, err + } + return VoteEntry{VoteOption{id, category, name}, 0}, nil +} + +func BulkInsertVoteEntry(names []string, category string) (*[]VoteEntry, error) { + stmnt := BULKINSERTVOTEENTRY + var entries []VoteEntry = make([]VoteEntry, len(names)) + for i, v := range names { + id, err := util.RandString(8) + if err != nil { + return nil, err + } + stmnt = stmnt + fmt.Sprintf(VALUESTEMPLATE, id, category, v, 0) + entries = append(entries, VoteEntry{VoteOption{id, category, v}, 0}) + if i < len(names)-1 { + stmnt = stmnt + "," + } + } + stmnt = stmnt + ";" + + _, err := db.Exec(stmnt) + + if err != nil { + return nil, err + } + + return &entries, nil +} + +func SetVote(id string, votes int) error { + _, err := db.Exec(SETVOTE, votes, id) + + if err != nil { + return err + } + return nil +} + +func IncVote(id string) error { + res, err := db.Exec(INCVOTE, id) + + if err != nil { + return err + } + + n, _ := res.RowsAffected() + + if n < 1 { + return errors.New("id does not exist") + } + + return nil +} + +func DelVoteEntry(id string) error { + res, err := db.Exec(DELVOTEENTRY, id) + + if err != nil { + return err + } + + n, _ := res.RowsAffected() + + if n < 1 { + return errors.New("id does not exist") + } + + return nil +} + +func BulkDelVoteEntry(ids []string) error { + + placeholders := make([]string, len(ids)) + + for i := 0; i < len(ids); i++ { + placeholders = append(placeholders, fmt.Sprintf("$%d", i)) + } + + res, err := db.Exec(fmt.Sprintf(BULKDELVOTEENTRY, strings.Join(placeholders, ", ")), ids) + + if err != nil { + return err + } + + n, _ := res.RowsAffected() + + if n < 1 { + return errors.New("id does not exist") + } + + return nil +} + +func GetVotes() ([]VoteEntry, error) { + res, err := db.Query(GETVOTES) + + if err != nil { + return nil, err + } + defer res.Close() + + var current VoteEntry + var entries []VoteEntry = []VoteEntry{} + + for res.Next() { + if err := res.Err(); err != nil { + return nil, err + } + if err := res.Scan(¤t.ID, ¤t.Category, ¤t.Name, ¤t.Votes); err != nil { + return nil, err + } + entries = append(entries, current) + } + + return entries, nil +} + +func GetVoteOptions() ([]VoteOption, error) { + res, err := db.Query(GETVOTEOPTIONS) + + if err != nil { + return nil, err + } + defer res.Close() + + var current VoteOption + var entries []VoteOption = []VoteOption{} + + for res.Next() { + if err := res.Err(); err != nil { + return nil, err + } + if err := res.Scan(¤t.ID, ¤t.Category, ¤t.Name); err != nil { + return nil, err + } + entries = append(entries, current) + } + + return entries, nil +} + +func GetVote(id string) (VoteEntry, error) { + res := db.QueryRow(GETVOTE, id) + + var current VoteEntry + + if err := res.Scan(¤t.ID, ¤t.Category, ¤t.Name, ¤t.Votes); err != nil { + return VoteEntry{}, err + } + + return current, nil +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..427c99a --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,46 @@ +package http + +import ( + "net/http" + + "tomatentum.net/outfit-voting-abi26/internal/auth" + "tomatentum.net/outfit-voting-abi26/internal/http/vote" +) + +var mux *http.ServeMux = http.NewServeMux() + +func Start() error { + mux.Handle("/", auth.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + mux.HandleFunc("/api/auth/logout", auth.LogoutEndpoint) + + mux.HandleFunc("GET /api/vote/locked", vote.VoteLockedEndpoint) + mux.HandleFunc("GET /api/result/locked", vote.ResultLockedEndpoint) + mux.Handle("PATCH /api/vote/locked", auth.AuthMiddleware(vote.SetVoteLockedEndpoint)) + mux.Handle("PATCH /api/result/locked", auth.AuthMiddleware(vote.SetResultLockedEndpoint)) + + mux.Handle("GET /api/vote", vote.VoteLockMiddleware(vote.GetVoteOptionsEndpoint)) + mux.Handle("GET /api/result", vote.ResultLockMiddleware(vote.GetVoteResultEndpoint)) + mux.Handle("GET /api/result/{id}", vote.ResultLockMiddleware(vote.GetVoteResultSingleEndoint)) + mux.Handle("PATCH /api/vote/{id}/override", auth.AuthMiddleware(vote.SetVoteEndpoint)) + mux.Handle("PATCH /api/vote/{id}/inc", vote.VoteLockMiddleware(vote.IncVoteEndpoint)) + mux.Handle("DELETE /api/vote/{id}", auth.AuthMiddleware(vote.DelVoteEntryEndpoint)) + mux.Handle("POST /api/vote", auth.AuthMiddleware(vote.AddVoteEntryEndpoint)) + + auth.CallbackHandler(mux) + return http.ListenAndServe(":4000", corsMiddleware(mux)) +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/vote/result.go b/internal/http/vote/result.go new file mode 100644 index 0000000..bc834a4 --- /dev/null +++ b/internal/http/vote/result.go @@ -0,0 +1,33 @@ +package vote + +import ( + "encoding/json" + "net/http" + + "tomatentum.net/outfit-voting-abi26/internal/database" +) + +func GetVoteResultEndpoint(w http.ResponseWriter, r *http.Request) { + entries, err := database.GetVotes() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func GetVoteResultSingleEndoint(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + entry, err := database.GetVote(id) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(entry) +} diff --git a/internal/http/vote/vote.go b/internal/http/vote/vote.go new file mode 100644 index 0000000..b70a913 --- /dev/null +++ b/internal/http/vote/vote.go @@ -0,0 +1,127 @@ +package vote + +import ( + "encoding/json" + "net/http" + + "tomatentum.net/outfit-voting-abi26/internal/database" +) + +type SetVoteRequest struct { + Votes int +} + +type AddVoteEntryRequest struct { + Name string + Category string +} + +type BulkAddVoteEntryRequest struct { + Category string + names []string +} + +func GetVoteOptionsEndpoint(w http.ResponseWriter, r *http.Request) { + entries, err := database.GetVoteOptions() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func SetVoteEndpoint(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var request SetVoteRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + if err := database.SetVote(id, request.Votes); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func IncVoteEndpoint(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + if err := database.IncVote(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func DelVoteEntryEndpoint(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + if err := database.DelVoteEntry(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func BulkDelVoteEntryEndpoint(w http.ResponseWriter, r *http.Request) { + var ids []string + if err := json.NewDecoder(r.Body).Decode(&ids); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + if err := database.BulkDelVoteEntry(ids); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func AddVoteEntryEndpoint(w http.ResponseWriter, r *http.Request) { + var request AddVoteEntryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + entry, err := database.InsertVoteEntry(request.Name, request.Category) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(entry) +} + +func BulkVoteEntryEndpoint(w http.ResponseWriter, r *http.Request) { + var request BulkAddVoteEntryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + entry, err := database.BulkInsertVoteEntry(request.names, request.Category) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(entry) +} diff --git a/internal/http/vote/votelock.go b/internal/http/vote/votelock.go new file mode 100644 index 0000000..61d285f --- /dev/null +++ b/internal/http/vote/votelock.go @@ -0,0 +1,124 @@ +package vote + +import ( + "encoding/json" + "log" + "net/http" + + "tomatentum.net/outfit-voting-abi26/internal/auth" + "tomatentum.net/outfit-voting-abi26/internal/database" +) + +type StateChangeRequest struct { + Value bool +} + +func SetVoteLockedEndpoint(w http.ResponseWriter, r *http.Request) { + var request StateChangeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + if err := database.SetVoteLocked(request.Value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Updated Vote Lock state to %t\n", request.Value) + + w.WriteHeader(http.StatusOK) +} + +func SetResultLockedEndpoint(w http.ResponseWriter, r *http.Request) { + var request StateChangeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + r.Body.Close() + + if err := database.SetResultLocked(request.Value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Updated Result Lock state to %t\n", request.Value) + + w.WriteHeader(http.StatusOK) +} + +func VoteLockedEndpoint(w http.ResponseWriter, r *http.Request) { + locked, err := database.GetVoteLocked() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(locked) +} + +func ResultLockedEndpoint(w http.ResponseWriter, r *http.Request) { + locked, err := database.GetResultLocked() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(locked) +} + +func VoteLockMiddleware(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok, err := auth.IsAuth(r) + if ok { + next.ServeHTTP(w, r) + return + } + log.Println("DEBUG: User not authenticated so vote lock is not bypassed", err) + + locked, err := database.GetVoteLocked() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if locked { + w.WriteHeader(http.StatusLocked) + w.Write([]byte("Vote currently locked!")) + return + } + next.ServeHTTP(w, r) + }) +} + +func ResultLockMiddleware(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok, err := auth.IsAuth(r) + if ok { + next.ServeHTTP(w, r) + return + } + log.Println("DEBUG: User not authenticated so result lock is not bypassed", err) + + locked, err := database.GetResultLocked() + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if locked { + w.WriteHeader(http.StatusLocked) + w.Write([]byte("Results currently locked!")) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/util/rand.go b/internal/util/rand.go new file mode 100644 index 0000000..a16a0f2 --- /dev/null +++ b/internal/util/rand.go @@ -0,0 +1,15 @@ +package util + +import ( + "crypto/rand" + "encoding/base64" + "io" +) + +func RandString(nByte int) (string, error) { + b := make([]byte, nByte) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +}