From 4168e926018424d05e45f4be6f3dc4955c3db4e5 Mon Sep 17 00:00:00 2001 From: Jonas Hahn Date: Thu, 18 Sep 2025 16:51:57 +0200 Subject: [PATCH] Full refactor of codebase --- .air.toml | 2 +- Makefile | 9 +- README.md | 6 +- cmd/server/main.go | 39 +++ default.env | 10 + go.mod | 2 +- src/auth.go | 42 --- src/config/config.go | 45 +++ src/handlers/enter.go | 227 ++++++++++++++ src/handlers/history.go | 61 ++++ src/handlers/index.go | 14 + src/handlers/leaderboard.go | 21 ++ src/handlers/login.go | 51 ++++ src/handlers/magic.go | 34 +++ src/handlers/routes.go | 37 +++ src/handlers/table.go | 106 +++++++ src/handlers/user.go | 57 ++++ src/main.go | 114 -------- src/middleware/auth.go | 20 ++ src/middleware/session.go | 37 +++ src/models.go | 71 ----- src/repository/crud.go | 76 +++++ src/repository/database.go | 32 ++ src/repository/lookup.go | 32 ++ src/repository/models.go | 67 +++++ src/repository/session.go | 42 +++ src/routes.go | 488 ------------------------------- src/{ => services}/elo.go | 51 ++-- src/services/mail.go | 48 +++ src/session.go | 85 ------ src/{ => templates}/templates.go | 32 +- src/utils.go | 202 ------------- src/utils/username.go | 36 +++ src/utils/utils.go | 42 +++ 34 files changed, 1176 insertions(+), 1062 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 default.env delete mode 100644 src/auth.go create mode 100644 src/config/config.go create mode 100644 src/handlers/enter.go create mode 100644 src/handlers/history.go create mode 100644 src/handlers/index.go create mode 100644 src/handlers/leaderboard.go create mode 100644 src/handlers/login.go create mode 100644 src/handlers/magic.go create mode 100644 src/handlers/routes.go create mode 100644 src/handlers/table.go create mode 100644 src/handlers/user.go delete mode 100644 src/main.go create mode 100644 src/middleware/auth.go create mode 100644 src/middleware/session.go delete mode 100644 src/models.go create mode 100644 src/repository/crud.go create mode 100644 src/repository/database.go create mode 100644 src/repository/lookup.go create mode 100644 src/repository/models.go create mode 100644 src/repository/session.go delete mode 100644 src/routes.go rename src/{ => services}/elo.go (82%) create mode 100644 src/services/mail.go delete mode 100644 src/session.go rename src/{ => templates}/templates.go (75%) delete mode 100644 src/utils.go create mode 100644 src/utils/username.go create mode 100644 src/utils/utils.go diff --git a/.air.toml b/.air.toml index 51e6897..14cf0bc 100644 --- a/.air.toml +++ b/.air.toml @@ -1,5 +1,5 @@ [build] -cmd = "nix develop -c go build -o ./tmp/main ./src/..." # only for nix users +cmd = "nix develop -c go build -o ./tmp/main ./cmd/server" # Only for nix users [log] time = false diff --git a/Makefile b/Makefile index 4f33ad5..10a89cd 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,5 @@ # Makefile for QRank # ###################### -# Main target -run: - go run ./src - -# Nix stuff -nix-run: - nix develop --command make run +setup: + cp default.env .env diff --git a/README.md b/README.md index 25d173b..3e2ccfe 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ The **easy** tracker for tabletop soccers with QR code integration. Everything is still in development. Feel free to open issues and or pull requests. -This application is build with [go](https://go.dev/) and [gin](https://gin-gonic.com/). Other languages like HTML and CSS are used. +This application is build with [go](https://go.dev/) and [gin](https://gin-gonic.com/) and a custom templating package. ## Getting started -To run the application go to the root directory and run `make run` in the terminal. On a nix system it is possible to run with all dependencies via `make nix-run`. The default listening address is `http://localhost:18765`. - +Setup with `make setup`. Then configure the environment file. +The default listening address is `http://localhost:18765`. For a development environment install [air](https://github.com/air-verse/air) and run the development server with `air` from the root directory. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..20fe1e1 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + + "strconv" + + "github.com/ascyii/qrank/src/config" + "github.com/ascyii/qrank/src/handlers" + "github.com/ascyii/qrank/src/middleware" + "github.com/ascyii/qrank/src/repository" + "github.com/gin-gonic/gin" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + // Create engine + if config.Config.Debug { + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + r := gin.Default() + + // Setup the session store + repository.SetupStore(r) + + // Setup engine + r.Use(middleware.SessionHandlerMiddleware()) + handlers.Register(r) + r.Static("/assets", "./assets") + + // Run the engine + bind := ":" + strconv.Itoa(config.Config.AppPort) + if err := r.Run(bind); err != nil { + log.Fatal(err) + } +} diff --git a/default.env b/default.env new file mode 100644 index 0000000..3da2dc7 --- /dev/null +++ b/default.env @@ -0,0 +1,10 @@ +DEBUG=True + +# Server configuration +APP_PORT= +APP_BASE_URL= + +# For sending mail +SMTP_HOST= +SMTP_MAIL= +SMTP_PASS= diff --git a/go.mod b/go.mod index a31b95f..af51337 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitlab.gwdg.de/qrank/qrank +module github.com/ascyii/qrank go 1.24.5 diff --git a/src/auth.go b/src/auth.go deleted file mode 100644 index 37e578c..0000000 --- a/src/auth.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "database/sql" - "net/http" - "strings" - - "github.com/gin-gonic/gin" -) - -func RequireAuthMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - if u := getSessionUser(c); u != nil { - c.Set("user", u) - c.Next() - return - } - c.Redirect(http.StatusFound, "/login") - c.Abort() - } -} - -func findUserByHandle(handle string) (*User, error) { - h := strings.TrimSpace(handle) - if h == "" { - return nil, sql.ErrNoRows - } - - // Return user by email or username - if strings.Contains(h, "@") { - if u, err := userByEmail(strings.ToLower(h)); err == nil { - return u, nil - } - } else { - // Find and return the user - if u, err := userByName(h); err == nil { - return u, nil - } - } - - return nil, sql.ErrNoRows -} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..b6073d1 --- /dev/null +++ b/src/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +var Config = struct { + BaseUrl string + Debug bool + CookieDomain string + AppPort int +}{ + BaseUrl: "http://localhost:18765", // Default configurations + AppPort: 18765, +} + +func init() { + var err error + + // Load the environment + err = godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + // Set debug config + Config.Debug = os.Getenv("DEBUG") == "True" + + // Get the listening port + port, _ := strconv.Atoi(os.Getenv("APP_PORT")) + if err != nil && port > 0 { + Config.AppPort = port + } + + // Set the base URL + baseURL := os.Getenv("APP_BASE_URL") + if baseURL != "" { + print("okay") + Config.BaseUrl = baseURL + } +} diff --git a/src/handlers/enter.go b/src/handlers/enter.go new file mode 100644 index 0000000..0a2c7f5 --- /dev/null +++ b/src/handlers/enter.go @@ -0,0 +1,227 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/services" + "github.com/ascyii/qrank/src/templates" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func getEnter(c *gin.Context) { + sess := sessions.Default(c) + tableSlug := sess.Get("TableSlug") + + var slug string + if tableSlug != nil { + slug = tableSlug.(string) + + } + + if slug != "" { + + var table *repository.Table + repository.GetDB().Model(&repository.Table{}).Where("slug = ?", slug).First(&table) + + templates.Render(c, "enter", gin.H{"Table": table}) + return + + } + templates.Render(c, "enter", gin.H{}) +} + +func postEnter(c *gin.Context) { + current := repository.FindUser(c) + if current == nil { + c.Redirect(http.StatusFound, "/login") + return + } + + sess := sessions.Default(c) + tableSlug := sess.Get("TableSlug") + + var slug string + var t *repository.Table + if tableSlug != nil { + slug = tableSlug.(string) + if slug != "" { + // Get the current table + repository.GetDB().Model(&repository.Table{}).Where("slug = ?", slug).First(&t) + } else { + t = nil + } + } + + // Parse form + p1h := strings.TrimSpace(c.PostForm("p1")) + p2h := strings.TrimSpace(c.PostForm("p2")) + p3h := strings.TrimSpace(c.PostForm("p3")) + p4h := strings.TrimSpace(c.PostForm("p4")) + + p1t := strings.TrimSpace(c.PostForm("team_p1")) + p2t := strings.TrimSpace(c.PostForm("team_p2")) + p3t := strings.TrimSpace(c.PostForm("team_p3")) + p4t := strings.TrimSpace(c.PostForm("team_p4")) + + scoreA, _ := strconv.Atoi(c.PostForm("scoreA")) + scoreB, _ := strconv.Atoi(c.PostForm("scoreB")) + + // Require a winner + if scoreA == scoreB { + repository.SaveForm(c) + repository.SetMessage(c, "There must be a winner") + c.Redirect(http.StatusSeeOther, "/enter") + return + } + + // Resolve players + resolveUser := func(handle string) *repository.User { + if handle == "" { + return nil + } + u := repository.FindUser(handle) + if u != nil { + repository.SaveForm(c) + repository.SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle)) + c.Redirect(http.StatusSeeOther, "/enter") + return nil + } + return u + } + + var p1, p2, p3, p4 *repository.User + + // Always ensure A1 exists, fallback to current if empty + var err error + if p1 = resolveUser(p1h); p1 != nil { + return + } + if p2 = resolveUser(p2h); p2 != nil { + return + } + if p3 = resolveUser(p3h); p3 != nil { + return + } + if p4 = resolveUser(p4h); p4 != nil { + return + } + + // Generate raw teams + var players = []*repository.GameUser{} + var team1ps, team2ps []*repository.GameUser + + users := []*repository.User{p1, p2, p3, p4} + sides := []string{p1t, p2t, p3t, p4t} + + var playerbuf []float64 + + for i, u := range users { + if u != nil { + if sides[i] == "A" || sides[i] == "B" { + gu := &repository.GameUser{User: *u, Side: sides[i], DeltaElo: 0} + players = append(players, gu) + playerbuf = append(playerbuf, u.Elo) + if sides[i] == "A" { + team1ps = append(team1ps, gu) + } else { + team2ps = append(team2ps, gu) + } + } + } + } + + // Check for at least one player on each side + if len(team1ps) == 0 || len(team2ps) == 0 { + repository.SaveForm(c) + repository.SetMessage(c, "There must be at least one player on each side") + c.Redirect(http.StatusSeeOther, "/enter") + return + } + + // Check for duplicate users + seen := map[uint]bool{} + for i, u := range users { + if u != nil { + if seen[u.ID] { + repository.SaveForm(c) + repository.SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username)) + c.Redirect(http.StatusSeeOther, "/enter") + return + } + seen[u.ID] = true + } + } + + // Create game + var g repository.Game + + if slug != "" { + g = repository.Game{ + ScoreA: scoreA, + ScoreB: scoreB, + TableID: &t.ID, + } + } else { + g = repository.Game{ + ScoreA: scoreA, + ScoreB: scoreB, + } + } + if err := repository.GetDB().Create(&g).Error; err != nil { + c.String(http.StatusInternalServerError, "game create error") + return + } + + team1 := services.NewTeam(team1ps, g.ScoreA) + team2 := services.NewTeam(team2ps, g.ScoreB) + + // Set new elo for all players + services.GetNewElo(team1, team2) + + var winningSide string + if g.ScoreA > g.ScoreB { + winningSide = "A" + } else { + winningSide = "B" + } + + for i, p := range players { + p.DeltaElo = players[i].User.Elo - playerbuf[i] + p.GameID = g.ID + p.UserID = p.User.ID + + var updateString string + if p.Side == winningSide { + updateString = "win_count" + } else { + updateString = "loss_count" + + } + var expr = gorm.Expr(updateString+" + ?", 1) + + // Update win or loss + err = repository.GetDB().Model(&repository.User{}). + Where("id = ?", p.User.ID). + UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error + if err != nil { + log.Println(err) + } + + if err := repository.GetDB().Create(p).Error; err != nil { + c.String(http.StatusInternalServerError, "Failed to assign player") + return + } + } + + sess.Delete("TableSlug") + sess.Save() + + c.Redirect(http.StatusFound, "/history") +} diff --git a/src/handlers/history.go b/src/handlers/history.go new file mode 100644 index 0000000..1d043ce --- /dev/null +++ b/src/handlers/history.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "fmt" + + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/templates" + "github.com/ascyii/qrank/src/utils" + "github.com/gin-gonic/gin" +) + +func getHistory(c *gin.Context) { + // Load recent games + var games []repository.Game + repository.GetDB().Preload("Table").Order("created_at desc").Find(&games) + + type userElo struct { + repository.User + + DeltaElo string + Color string + } + + type gRow struct { + repository.Game + + PlayersA []userElo + PlayersB []userElo + WinnerIsA bool + } + var rows []gRow + for _, g := range games { + var gps []repository.GameUser + repository.GetDB().Model(&repository.GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps) + var a, b []userElo + for _, gp := range gps { + var eloColor, eloFloatString string + eloFloat := utils.RoundFloat(gp.DeltaElo, 2) + + if eloFloat > 0 { + eloColor = "green" + eloFloatString = fmt.Sprintf("+%.2f", eloFloat) + } else if eloFloat < 0 { + eloColor = "red" + eloFloatString = fmt.Sprintf("%.2f", eloFloat) + } else { + eloColor = "black" + eloFloatString = fmt.Sprintf("%.2f", eloFloat) + } + + if gp.Side == "A" { + a = append(a, userElo{gp.User, eloFloatString, eloColor}) + } else { + b = append(b, userElo{gp.User, eloFloatString, eloColor}) + } + } + rows = append(rows, gRow{Game: g, PlayersA: a, PlayersB: b, WinnerIsA: g.ScoreA > g.ScoreB}) + + } + templates.Render(c, "history", gin.H{"Games": rows}) +} diff --git a/src/handlers/index.go b/src/handlers/index.go new file mode 100644 index 0000000..28ec3e8 --- /dev/null +++ b/src/handlers/index.go @@ -0,0 +1,14 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "github.com/ascyii/qrank/src/repository" +) + +func getIndex(c *gin.Context) { + if u := repository.FindUser(c); u != nil { + c.Redirect(302, "/enter") + return + } + getLogin(c) +} diff --git a/src/handlers/leaderboard.go b/src/handlers/leaderboard.go new file mode 100644 index 0000000..a7829c2 --- /dev/null +++ b/src/handlers/leaderboard.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/templates" +) + +func getLeaderboard(c *gin.Context) { + var users []repository.User + if err := repository.GetDB().Order("elo DESC").Find(&users).Error; err != nil { + c.String(http.StatusInternalServerError, "Error: %v", err) + return + } + + templates.Render(c, "leaderboard", gin.H{ + "Users": users, + }) +} diff --git a/src/handlers/login.go b/src/handlers/login.go new file mode 100644 index 0000000..8af1022 --- /dev/null +++ b/src/handlers/login.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "log" + "strings" + "time" + + "github.com/ascyii/qrank/src/config" + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/services" + "github.com/ascyii/qrank/src/templates" + "github.com/ascyii/qrank/src/utils" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func getLogin(c *gin.Context) { + templates.Render(c, "login", gin.H{}) +} + +func postLogin(c *gin.Context) { + email := strings.ToLower(strings.TrimSpace(c.PostForm("email"))) + if email == "" { + c.Redirect(302, "/login") + return + } + + // create token valid 30 days + token := utils.MustRandToken(24) + lt := repository.LoginToken{Token: token, Email: email, ExpiresAt: utils.Now().Add(30 * 24 * time.Hour)} + if err := repository.GetDB().Create(<).Error; err != nil { + log.Println("create token:", err) + } + + link := strings.TrimRight(config.Config.BaseUrl, "/") + "/magic?token=" + token + log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339)) + + if err := services.SendEmail(email, link); err != nil { + c.Status(500) + } + + templates.Render(c, "sent", gin.H{"Email": email}) +} + +func getLogout(c *gin.Context) { + sess := sessions.Default(c) + sess.Delete("user_id") + sess.Save() + + c.Redirect(302, "/enter") +} diff --git a/src/handlers/magic.go b/src/handlers/magic.go new file mode 100644 index 0000000..552adf7 --- /dev/null +++ b/src/handlers/magic.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/utils" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func getMagic(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.Redirect(302, "/login") + return + } + var lt repository.LoginToken + if err := repository.GetDB().Where("token = ? AND expires_at > ?", token, utils.Now()).First(<).Error; err != nil { + c.String(400, "Invalid or expired token") + return + } + + // Find or create user + u := repository.FindOrCreateUserFromEmail(lt.Email) + if u == nil { + c.String(500, "User not found") + return + } + + sess := sessions.Default(c) + sess.Set("user_id", u.ID) + sess.Save() + + c.Redirect(302, "/enter") +} diff --git a/src/handlers/routes.go b/src/handlers/routes.go new file mode 100644 index 0000000..8f4157f --- /dev/null +++ b/src/handlers/routes.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "github.com/gin-gonic/gin" + "github.com/ascyii/qrank/src/middleware" +) + +func Register(r *gin.Engine) { + // Routes + r.GET("/", getIndex) + r.GET("/login", getLogin) + r.POST("/login", postLogin) + r.GET("/magic", getMagic) + r.GET("/qr/:qrSlug", getQr) + + // Authenticated routes + authorized := r.Group("/") + authorized.Use(middleware.RequireAuth()) + { + authorized.GET("/logout", getLogout) + + authorized.GET("/enter", getEnter) + authorized.POST("/enter", postEnter) + + // QR-prepped table routes + authorized.GET("/table/:tableSlug", getTable) + authorized.POST("/table/:tableSlug", postTable) + authorized.POST("/table/:tableSlug/reset", postTableReset) + + authorized.GET("/history", getHistory) + authorized.GET("/leaderboard", getLeaderboard) + + authorized.GET("/user/:userSlug", getUserView) + authorized.GET("/me", getMe) + authorized.POST("/me", postMe) + } +} diff --git a/src/handlers/table.go b/src/handlers/table.go new file mode 100644 index 0000000..4fb088f --- /dev/null +++ b/src/handlers/table.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/templates" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + qrcode "github.com/skip2/go-qrcode" +) + +var tables = make(map[string]*TableInfo) + +// Memory table store +type TableInfo struct { + Players map[uint]*repository.User + PlayerCount int +} + +func getTable(c *gin.Context) { + slug := c.Param("tableSlug") + u := repository.FindUser(c) + if tables[slug] == nil { + tables[slug] = &TableInfo{} + } + + table := tables[slug] + if tables[slug].Players == nil { + tables[slug].Players = make(map[uint]*repository.User) + } + + if table.PlayerCount >= 4 { + templates.Render(c, "table_status", gin.H{"Full": "true", "CurrentSlug": slug, "Table": tables[slug]}) + return + } + + table.Players[u.ID] = u + table.PlayerCount = len(table.Players) + + templates.Render(c, "table_status", gin.H{"CurrentSlug": slug, "Table": tables[slug]}) +} + +func postTable(c *gin.Context) { + slug := c.Param("tableSlug") + table := tables[slug] + session := sessions.Default(c) + + if len(table.Players) == 2 || len(table.Players) == 4 { + + // Pretend we got some values from somewhere else + var count int + tags := []string{"a1", "b1", "a2", "b2"} + formData := make(map[string]string) + for _, value := range table.Players { + formData[tags[count]] = value.Username + count++ + } + if b, err := json.Marshal(formData); err == nil { + session.Set("form_data", string(b)) + _ = session.Save() + } + + session.Set("TableSlug", slug) + _ = session.Save() + + // Reset the table + tables[slug] = &TableInfo{} + + repository.GetDB().Save(&repository.Table{Slug: slug}) + + c.Redirect(302, "/enter") + return + + } + repository.SetMessage(c, "Wrong amount of players!") + c.Redirect(302, "/table/"+slug) + +} + +func postTableReset(c *gin.Context) { + slug := c.Param("tableSlug") + tables[slug] = &TableInfo{} + repository.SetMessage(c, "Resettet the table!") + c.Redirect(302, "/table/"+slug) +} + +// Get qr for table +func getQr(c *gin.Context) { + slug := c.Param("qrSlug") + var url string + if slug == "" { + url = "http://localhost:18765" + } else { + url = "http://localhost:18765/table/" + slug + } + + png, err := qrcode.Encode(url, qrcode.Medium, 256) + if err != nil { + c.String(http.StatusInternalServerError, "could not generate QR") + return + } + + c.Data(http.StatusOK, "image/png", png) +} diff --git a/src/handlers/user.go b/src/handlers/user.go new file mode 100644 index 0000000..c1ca716 --- /dev/null +++ b/src/handlers/user.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "log" + "strings" + + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/templates" + "github.com/ascyii/qrank/src/utils" + "github.com/gin-gonic/gin" +) + +func getUserView(c *gin.Context) { + slug := c.Param("userSlug") + u := repository.FindUser(slug) + if u == nil { + c.String(404, "User not found") + return + } + + // Check if own user + var own bool + if slug == u.Slug { + own = true + } + + templates.Render(c, "user", gin.H{"User": u, "Own": own}) +} + +func getMe(c *gin.Context) { + u := repository.FindUser(c) + templates.Render(c, "user", gin.H{"User": u, "Own": true}) +} + +func postMe(c *gin.Context) { + u := repository.FindUser(c) + if u == nil { + c.Redirect(302, "/login") + return + } + newU := strings.TrimSpace(c.PostForm("username")) + if newU == "" || newU == u.Username { + c.Redirect(302, "/me") + return + } + + // Update username and slug + u.Username = newU + u.Slug = utils.Slugify(newU) + if err := repository.EnsureUniqueUsernameAndSlug(u); err != nil { + log.Println("unique username error:", err) + } + if err := repository.GetDB().Save(u).Error; err != nil { + log.Println("save user:", err) + } + c.Redirect(302, "/me") +} diff --git a/src/main.go b/src/main.go deleted file mode 100644 index 62a56e2..0000000 --- a/src/main.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "log" - "os" - - "strconv" - - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/joho/godotenv" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// ===================== Globals ===================== - -var ( - db *gorm.DB - baseURL string - cookieDomain string - - // Global logger for this package - lg = log.Default() -) - -const ( - // Default port if APP_PORT is not set - defaultPort = 18765 -) - -// ===================== Main ===================== - -func main() { - log.SetFlags(log.LstdFlags | log.Lshortfile) - - var err error - err = godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - - // Get the listening port - port := os.Getenv("APP_PORT") - if port == "" { - port = strconv.Itoa(defaultPort) - } - - // Set the base URL - baseURL = os.Getenv("APP_BASE_URL") - if baseURL == "" { - baseURL = "http://localhost:" + port - } - - // Open connection to SQLite - db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) - if err != nil { - lg.Fatal("sqlite connect:", err) - } - - if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GameUser{}); err != nil { - lg.Fatal("migrate:", err) - } - if err := db.SetupJoinTable(&User{}, "Games", &GameUser{}); err != nil { - lg.Fatal("setup jointable:", err) - } - - // Create engine - gin.SetMode(gin.ReleaseMode) - r := gin.Default() - - store := cookie.NewStore([]byte("secret")) - r.Use(sessions.Sessions("mysession", store)) - r.Use(SessionHandlerMiddleware()) - - // Serve static files from the current directory - r.Static("/assets", "./assets") - - // Routes - r.GET("/", getIndex) - r.GET("/login", getLogin) - r.POST("/login", postLogin) - r.GET("/magic", getMagic) - r.GET("/qr/:qrSlug", getQr) - - authorized := r.Group("/") - authorized.Use(RequireAuthMiddleware()) - { - - // Authenticated routes - authorized.GET("/enter", getEnter) - authorized.POST("/enter", postEnter) - - // QR-prepped table routes - authorized.GET("/table/:tableSlug", getTable) - authorized.POST("/table/:tableSlug", postTable) - authorized.POST("/table/:tableSlug/reset", postTableReset) - - authorized.GET("/history", getHistory) - authorized.GET("/leaderboard", getLeaderboard) - - authorized.GET("/user/:userSlug", getUserView) - authorized.GET("/me", getMe) - authorized.POST("/me", postMe) - } - - // Start application with port - bind := ":" + port - //lg.Println("Listening on", baseURL) - if err := r.Run(bind); err != nil { - lg.Fatal(err) - } -} diff --git a/src/middleware/auth.go b/src/middleware/auth.go new file mode 100644 index 0000000..96eefca --- /dev/null +++ b/src/middleware/auth.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "net/http" + + "github.com/ascyii/qrank/src/repository" + "github.com/gin-gonic/gin" +) + +func RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if u := repository.FindUser(c); u != nil { + c.Keys["user"] = u + c.Next() + return + } + c.Redirect(http.StatusFound, "/login") + c.Abort() + } +} diff --git a/src/middleware/session.go b/src/middleware/session.go new file mode 100644 index 0000000..8f2250e --- /dev/null +++ b/src/middleware/session.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "encoding/json" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// Middleware injects form data from session into context for templates +func SessionHandlerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + raw := session.Get("form_data") + + form := map[string]string{} + if raw != nil { + if data, ok := raw.(string); ok { + _ = json.Unmarshal([]byte(data), &form) + } + // clear after first use + session.Delete("form_data") + _ = session.Save() + } + + // make available in context + c.Set("form", form) + + // Set message in context when there is one available in the session + message := session.Get("message") + c.Set("mes", message) + session.Delete("message") + + _ = session.Save() + c.Next() + } +} diff --git a/src/models.go b/src/models.go deleted file mode 100644 index e0376e1..0000000 --- a/src/models.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "time" - - "gorm.io/gorm" -) - -// One knows that the user is active when there exists a session for the user -type User struct { - gorm.Model - - Email string `gorm:"uniqueIndex;size:320"` - Username string `gorm:"uniqueIndex;size:64"` - Slug string `gorm:"uniqueIndex;size:128"` - - Elo float64 `gorm:"default:1500"` // Current Elo rating - GameCount int `gorm:"default:0"` - WinCount int `gorm:"default:0"` - LossCount int `gorm:"default:0"` - - Games []Game `gorm:"many2many:game_users;"` -} - -type Game struct { - gorm.Model - TableID *uint `gorm:"index"` - Table *Table - - // Test if this is needed - Users []User `gorm:"many2many:game_users;"` - - ScoreA int - ScoreB int -} - -// Join table between Game and User with extra fields -type GameUser struct { - gorm.Model - GameID uint `gorm:"primaryKey"` - UserID uint `gorm:"primaryKey"` - - Side string `gorm:"size:1"` - DeltaElo float64 - - // Eager loading - Game Game - User User -} - -// Currently no expiry -type Session struct { - gorm.Model - UserID uint `gorm:"index"` - User User - - Token string `gorm:"uniqueIndex;size:128"` -} - -type LoginToken struct { - gorm.Model - Token string `gorm:"uniqueIndex;size:128"` - Email string `gorm:"index;size:320"` - ExpiresAt time.Time `gorm:"index"` -} - -type Table struct { - gorm.Model - Name string - Slug string `gorm:"uniqueIndex;size:128"` -} diff --git a/src/repository/crud.go b/src/repository/crud.go new file mode 100644 index 0000000..609994f --- /dev/null +++ b/src/repository/crud.go @@ -0,0 +1,76 @@ +package repository + +import ( + "errors" + "log" + "strings" + + "github.com/ascyii/qrank/src/utils" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func FindUser(ref any) *User { + // Session case + if c, ok := ref.(*gin.Context); ok { + session := sessions.Default(c) + if uid := session.Get("user_id"); uid != nil { + var u User + if err := db.First(&u, uid.(uint)).Error; err == nil { + return &u + } + return nil + } + } + + // String handle case + if h, ok := ref.(string); ok { + h = strings.TrimSpace(h) + if h == "" { + return nil + } + + var u User + var err error + if strings.Contains(h, "@") { + err = db.Where("LOWER(email) = ?", strings.ToLower(h)).First(&u).Error + } else { + err = db.Where("username = ?", h).First(&u).Error + } + + if err == nil { + return &u + } + } + + return nil +} + +func FindOrCreateUserFromEmail(email string) *User { + var u User + // Try to find existing user + err := db.Where("email = ?", email).First(&u).Error // TODO: This makes bad logs + if err == nil { + return &u + } + + if !errors.Is(err, gorm.ErrRecordNotFound) { + log.Println("Find user:", err) + return nil + } + + // Create a new user + u = User{ + Email: email, + Username: utils.DefaultUsername(), + } + if err := EnsureUniqueUsernameAndSlug(&u); err != nil { + log.Println("Ensure unique:", err) + } + if err := db.Create(&u).Error; err != nil { + log.Println("Create user:", err) + return nil + } + return &u +} diff --git a/src/repository/database.go b/src/repository/database.go new file mode 100644 index 0000000..8a058cd --- /dev/null +++ b/src/repository/database.go @@ -0,0 +1,32 @@ +package repository + +import ( + "log" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var db *gorm.DB + +func init() { + var err error + + // Open connection to sqlite + db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{}) + if err != nil { + log.Fatal("Sqlite connect:", err) + } + + // Migrate the database with gorm + if err := db.AutoMigrate(&User{}, &LoginToken{}, &Table{}, &Game{}, &GameUser{}); err != nil { + log.Fatal("Migrate:", err) + } + if err := db.SetupJoinTable(&User{}, "Games", &GameUser{}); err != nil { + log.Fatal("Setup jointable:", err) + } +} + +func GetDB() *gorm.DB { + return db +} diff --git a/src/repository/lookup.go b/src/repository/lookup.go new file mode 100644 index 0000000..145effc --- /dev/null +++ b/src/repository/lookup.go @@ -0,0 +1,32 @@ +package repository + +import ( + "errors" + + "github.com/ascyii/qrank/src/utils" +) + +func EnsureUniqueUsernameAndSlug(u *User) error { + baseU := u.Username + baseS := utils.Slugify(u.Username) + for i := range 5 { + candU := baseU + candS := baseS + if i > 0 { + suffix := "-" + utils.MustRandToken(1) + candU = baseU + suffix + candS = baseS + suffix + } + var cnt int64 + db.Model(&User{}).Where("username = ?", candU).Count(&cnt) + if cnt == 0 { + db.Model(&User{}).Where("slug = ?", candS).Count(&cnt) + if cnt == 0 { + u.Username = candU + u.Slug = candS + return nil + } + } + } + return errors.New("cannot create unique username/slug") +} diff --git a/src/repository/models.go b/src/repository/models.go new file mode 100644 index 0000000..0cbb421 --- /dev/null +++ b/src/repository/models.go @@ -0,0 +1,67 @@ +package repository + +import ( + "time" + + "gorm.io/gorm" +) + +// One knows that the user is active when there exists a session for the user +type User struct { + gorm.Model + + Email string + Username string + Slug string + + Elo float64 + GameCount int + WinCount int + LossCount int + + Games []Game `gorm:"many2many:game_user"` + LastLogin time.Time + Active bool +} + +type Table struct { + gorm.Model + + Name string + Slug string + GameCount int +} + +type Game struct { + gorm.Model + + TableID *uint + Table *Table + + Users []User `gorm:"many2many:game_user"` + + ScoreA int + ScoreB int +} + +// Join table between game and user with extra fields +type GameUser struct { + gorm.Model + + GameID uint + Game Game + + UserID uint + User User + + Side string `gorm:"size:1"` + DeltaElo float64 +} + +type LoginToken struct { + gorm.Model + + Token string + Email string + ExpiresAt time.Time +} diff --git a/src/repository/session.go b/src/repository/session.go new file mode 100644 index 0000000..f8fe48d --- /dev/null +++ b/src/repository/session.go @@ -0,0 +1,42 @@ +package repository + +import ( + "encoding/json" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +var store cookie.Store + +func init() { + store = cookie.NewStore([]byte("secret")) +} + +func SetupStore(r *gin.Engine) { + r.Use(sessions.Sessions("mysession", store)) +} + +// SaveForm stores submitted POST form data into the session +func SaveForm(c *gin.Context) { + session := sessions.Default(c) + form := map[string]string{} + for key, values := range c.Request.PostForm { + if len(values) > 0 { + form[key] = values[0] + } + } + + if b, err := json.Marshal(form); err == nil { + session.Set("form_data", string(b)) + session.Save() + } +} + +// SaveForm stores submitted POST form data into the session +func SetMessage(c *gin.Context, message string) { + session := sessions.Default(c) + session.Set("message", message) + session.Save() +} diff --git a/src/routes.go b/src/routes.go deleted file mode 100644 index 5a5c97d..0000000 --- a/src/routes.go +++ /dev/null @@ -1,488 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "math" - "net/http" - "strings" - "time" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -func getEnter(c *gin.Context) { - sess := sessions.Default(c) - tableSlug := sess.Get("TableSlug") - - var slug string - if tableSlug != nil { - slug = tableSlug.(string) - - } - - if slug != "" { - - var table *Table - db.Model(&Table{}).Where("slug = ?", slug).First(&table) - - tm.Render(c, "enter", gin.H{"Table": table}) - return - - } - tm.Render(c, "enter", gin.H{}) -} - -func postEnter(c *gin.Context) { - current := getSessionUser(c) - if current == nil { - c.Redirect(http.StatusFound, "/login") - return - } - - sess := sessions.Default(c) - tableSlug := sess.Get("TableSlug") - - var slug string - var t *Table - if tableSlug != nil { - slug = tableSlug.(string) - if slug != "" { - // Get the current table - db.Model(&Table{}).Where("slug = ?", slug).First(&t) - } else { - t = nil - } - } - - // Parse form - p1h := strings.TrimSpace(c.PostForm("p1")) - p2h := strings.TrimSpace(c.PostForm("p2")) - p3h := strings.TrimSpace(c.PostForm("p3")) - p4h := strings.TrimSpace(c.PostForm("p4")) - - p1t := strings.TrimSpace(c.PostForm("team_p1")) - p2t := strings.TrimSpace(c.PostForm("team_p2")) - p3t := strings.TrimSpace(c.PostForm("team_p3")) - p4t := strings.TrimSpace(c.PostForm("team_p4")) - - scoreA := atoiSafe(c.PostForm("scoreA")) - scoreB := atoiSafe(c.PostForm("scoreB")) - - // Require a winner - if scoreA == scoreB { - SaveForm(c) - SetMessage(c, "There must be a winner") - c.Redirect(http.StatusSeeOther, "/enter") - return - } - - // Resolve players - resolveUser := func(handle string) (*User, error) { - if handle == "" { - return nil, nil - } - u, err := findUserByHandle(handle) - if err != nil { - SaveForm(c) - SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle)) - c.Redirect(http.StatusSeeOther, "/enter") - return nil, err - } - return u, nil - } - - var p1, p2, p3, p4 *User - - // Always ensure A1 exists, fallback to current if empty - var err error - if p1, err = resolveUser(p1h); err != nil { - return - } - if p2, err = resolveUser(p2h); err != nil { - return - } - if p3, err = resolveUser(p3h); err != nil { - return - } - if p4, err = resolveUser(p4h); err != nil { - return - } - - // Generate raw teams - var players []Player - var team1ps, team2ps []Player - users := []*User{p1, p2, p3, p4} - sides := []string{p1t, p2t, p3t, p4t} - var playerbuf []float64 - - for i, u := range users { - if u != nil { - if sides[i] == "A" || sides[i] == "B" { - players = append(players, Player{u, sides[i], 0}) - playerbuf = append(playerbuf, u.Elo) - if sides[i] == "A" { - team1ps = append(team1ps, Player{u, sides[i], 0}) - } else { - team2ps = append(team2ps, Player{u, sides[i], 0}) - } - } - } - } - - // Check for at least one player on each side - if len(team1ps) == 0 || len(team2ps) == 0 { - SaveForm(c) - SetMessage(c, "There must be at least one player on each side") - c.Redirect(http.StatusSeeOther, "/enter") - return - } - - // Check for duplicate users - seen := map[uint]bool{} - for i, u := range users { - if u != nil { - if seen[u.ID] { - SaveForm(c) - SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username)) - c.Redirect(http.StatusSeeOther, "/enter") - return - } - seen[u.ID] = true - } - } - - // Create game - var g Game - - if slug != "" { - g = Game{ - ScoreA: scoreA, - ScoreB: scoreB, - TableID: &t.ID, - } - } else { - g = Game{ - ScoreA: scoreA, - ScoreB: scoreB, - } - } - if err := db.Create(&g).Error; err != nil { - c.String(http.StatusInternalServerError, "game create error") - return - } - - team1 := NewTeam(team1ps, g.ScoreA) - team2 := NewTeam(team2ps, g.ScoreB) - - // Set new elo for all players - GetNewElo([]*Team{team1, team2}) - - var winningSide string - if g.ScoreA > g.ScoreB { - winningSide = "A" - } else { - winningSide = "B" - } - - for i, p := range players { - db.Save(p.u) - p.dElo = players[i].u.Elo - playerbuf[i] - - var updateString string - if p.side == winningSide { - updateString = "win_count" - } else { - updateString = "loss_count" - - } - var expr = gorm.Expr(updateString+" + ?", 1) - - // Update win or loss - err = db.Model(&User{}). - Where("id = ?", p.u.ID). - UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error - if err != nil { - log.Println(err) - } - - if err := db.Create(&GameUser{GameID: g.ID, UserID: p.u.ID, Side: p.side, DeltaElo: p.dElo}).Error; err != nil { - c.String(http.StatusInternalServerError, "failed to assign player") - return - } - } - - sess.Delete("TableSlug") - sess.Save() - - c.Redirect(http.StatusFound, "/history") -} - -func getIndex(c *gin.Context) { - if u := getSessionUser(c); u != nil { - c.Redirect(302, "/enter") - return - } - getLogin(c) -} - -func roundFloat(val float64, precision uint) float64 { - ratio := math.Pow(10, float64(precision)) - return math.Round(val*ratio) / ratio -} - -func getHistory(c *gin.Context) { - // Load recent games - var games []Game - db.Preload("Table").Order("created_at desc").Find(&games) - - type UserElo struct { - User - - DeltaElo string - Color string - } - - type GRow struct { - Game - PlayersA []UserElo - PlayersB []UserElo - WinnerIsA bool - } - var rows []GRow - for _, g := range games { - var gps []GameUser - db.Model(&GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps) - var a, b []UserElo - for _, gp := range gps { - var eloColor, eloFloatString string - eloFloat := roundFloat(gp.DeltaElo, 2) - - if eloFloat > 0 { - eloColor = "green" - eloFloatString = fmt.Sprintf("+%.2f", eloFloat) - } else if eloFloat < 0 { - eloColor = "red" - eloFloatString = fmt.Sprintf("%.2f", eloFloat) - } else { - eloColor = "black" - eloFloatString = fmt.Sprintf("%.2f", eloFloat) - } - - if gp.Side == "A" { - a = append(a, UserElo{gp.User, eloFloatString, eloColor}) - } else { - b = append(b, UserElo{gp.User, eloFloatString, eloColor}) - } - } - rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b, WinnerIsA: g.ScoreA > g.ScoreB}) - - } - tm.Render(c, "history", gin.H{"Games": rows}) -} - -func getLeaderboard(c *gin.Context) { - var users []User - if err := db.Order("elo DESC").Find(&users).Error; err != nil { - c.String(http.StatusInternalServerError, "Error: %v", err) - return - } - - tm.Render(c, "leaderboard", gin.H{ - "Users": users, - }) -} - -func getUserView(c *gin.Context) { - slug := c.Param("userSlug") - u, err := userBySlug(slug) - if err != nil { - c.String(404, "user not found") - return - } - - // Check if own user - var own bool - if slug == u.Slug { - own = true - } - - tm.Render(c, "user", gin.H{"User": u, "Own": own}) -} - -func getMe(c *gin.Context) { - u := getSessionUser(c) - tm.Render(c, "user", gin.H{"User": u, "Own": true}) -} - -func postMe(c *gin.Context) { - cu := getSessionUser(c) - if cu == nil { - c.Redirect(302, "/login") - return - } - newU := strings.TrimSpace(c.PostForm("username")) - if newU == "" || newU == cu.Username { - c.Redirect(302, "/me") - return - } - - // Update username and slug - cu.Username = newU - cu.Slug = slugify(newU) - if err := ensureUniqueUsernameAndSlug(cu); err != nil { - log.Println("unique username error:", err) - } - if err := db.Save(cu).Error; err != nil { - log.Println("save user:", err) - } - c.Redirect(302, "/me") -} - -func getLogin(c *gin.Context) { - tm.Render(c, "login", gin.H{}) -} - -func postLogin(c *gin.Context) { - email := strings.ToLower(strings.TrimSpace(c.PostForm("email"))) - if email == "" { - c.Redirect(302, "/login") - return - } - - // ensure user exists - var u User - tx := db.Where("email = ?", email).First(&u) - if errors.Is(tx.Error, gorm.ErrRecordNotFound) { - u = User{Email: email, Username: defaultUsername()} - if err := ensureUniqueUsernameAndSlug(&u); err != nil { - log.Println(err) - } - if err := db.Create(&u).Error; err != nil { - log.Println("create user:", err) - } - } - - // create token valid 30 days - token := mustRandToken(24) - lt := LoginToken{Token: token, Email: email, ExpiresAt: now().Add(30 * 24 * time.Hour)} - if err := db.Create(<).Error; err != nil { - log.Println("create token:", err) - } - - link := strings.TrimRight(baseURL, "/") + "/magic?token=" + token - log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339)) - - if err := sendEmail(email, link); err != nil { - c.Status(500) - } - - tm.Render(c, "sent", gin.H{"Email": email}) -} - -func getMagic(c *gin.Context) { - token := c.Query("token") - if token == "" { - c.Redirect(302, "/login") - return - } - var lt LoginToken - if err := db.Where("token = ? AND expires_at > ?", token, now()).First(<).Error; err != nil { - c.String(400, "Invalid or expired token") - return - } - // find or create user by email again (in case) - u, err := userByEmail(lt.Email) - if err != nil { - c.String(500, "user lookup error") - return - } - - // create session - sessTok := mustRandToken(24) - if err := db.Create(&Session{Token: sessTok, UserID: u.ID}).Error; err != nil { - c.String(500, "session error") - return - } - setSessionCookie(c, sessTok) - - c.Redirect(302, "/enter") -} - -type TableInfo struct { - Players map[uint]*User - PlayerCount int -} - -var tables = make(map[string]*TableInfo) - -func getTable(c *gin.Context) { - slug := c.Param("tableSlug") - u := getSessionUser(c) - if tables[slug] == nil { - tables[slug] = &TableInfo{} - } - - table := tables[slug] - if tables[slug].Players == nil { - tables[slug].Players = make(map[uint]*User) - } - - if table.PlayerCount >= 4 { - tm.Render(c, "table_status", gin.H{"Full": "true", "CurrentSlug": slug, "Table": tables[slug]}) - return - } - - table.Players[u.ID] = u - table.PlayerCount = len(table.Players) - - tm.Render(c, "table_status", gin.H{"CurrentSlug": slug, "Table": tables[slug]}) -} - -func postTable(c *gin.Context) { - slug := c.Param("tableSlug") - table := tables[slug] - session := sessions.Default(c) - - if len(table.Players) == 2 || len(table.Players) == 4 { - - // Pretend we got some values from somewhere else - var count int - tags := []string{"a1", "b1", "a2", "b2"} - formData := make(map[string]string) - for _, value := range table.Players { - formData[tags[count]] = value.Username - count++ - } - if b, err := json.Marshal(formData); err == nil { - session.Set("form_data", string(b)) - _ = session.Save() - } - - session.Set("TableSlug", slug) - _ = session.Save() - - // Reset the table - tables[slug] = &TableInfo{} - - db.Save(&Table{Slug: slug}) - - c.Redirect(302, "/enter") - return - - } - SetMessage(c, "Wrong amount of players!") - c.Redirect(302, "/table/"+slug) - -} - -func postTableReset(c *gin.Context) { - slug := c.Param("tableSlug") - tables[slug] = &TableInfo{} - SetMessage(c, "Resettet the table!") - c.Redirect(302, "/table/"+slug) -} diff --git a/src/elo.go b/src/services/elo.go similarity index 82% rename from src/elo.go rename to src/services/elo.go index 276e8e3..793eea2 100644 --- a/src/elo.go +++ b/src/services/elo.go @@ -1,4 +1,4 @@ -// Package elo implements a practical Elo rating update for head-to-head sports +// File elo implements a practical Elo rating update for head-to-head sports // such as table soccer. This variant has: // - No home/away asymmetry // - Margin of victory (MoV) factor using a robust bounded form @@ -8,14 +8,26 @@ // - Per-match delta cap // - No time weighting and no explicit uncertainty modeling -package main +package services import ( "errors" "math" + + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/utils" ) -var eloCfg = Config{ +var eloCfg = struct { + Mu0 float64 + Scale float64 // KNew applies to players during burn-in (first BurnInGames matches). + KNew float64 + KStd float64 + BurnInGames int + RatingFloor float64 + MaxPerMatchDelta float64 + MaxGoals int +}{ Mu0: 1500, Scale: 400, KNew: 48, @@ -26,34 +38,16 @@ var eloCfg = Config{ MaxGoals: 10, } -type Config struct { - Mu0 float64 - Scale float64 // KNew applies to players during burn-in (first BurnInGames matches). - KNew float64 - KStd float64 - BurnInGames int - RatingFloor float64 - MaxPerMatchDelta float64 - MaxGoals int -} - type Team struct { - Players []Player + Players []*repository.GameUser Score int AverageRating float64 } -// Collect players by side -type Player struct { - u *User - side string - dElo float64 -} - -func NewTeam(players []Player, score int) *Team { +func NewTeam(players []*repository.GameUser, score int) *Team { var t1s float64 = 0 for _, player := range players { - t1s += float64(player.u.Elo) + t1s += float64(player.User.Elo) } t1a := t1s / float64(len(players)) @@ -61,10 +55,8 @@ func NewTeam(players []Player, score int) *Team { return &Team{Players: players, Score: score, AverageRating: t1a} } -func GetNewElo(teams []*Team) error { +func GetNewElo(t1, t2 *Team) error { // Update the teams - t1 := teams[0] - t2 := teams[1] if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals { return errors.New("goals out of allowed range") } @@ -73,14 +65,15 @@ func GetNewElo(teams []*Team) error { } // Expected score (Bradley–Terry with logistic base-10) + teams := []*Team{t1, t2} for i, t := range teams { otherTeam := teams[1-i] for i, p := range t.Players { - newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount) + newRating, err := CalculateRating(p.User.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.User.GameCount) if err != nil { return err } - t.Players[i].u.Elo = roundFloat(newRating, 1) + t.Players[i].User.Elo = utils.RoundFloat(newRating, 1) } } diff --git a/src/services/mail.go b/src/services/mail.go new file mode 100644 index 0000000..a904364 --- /dev/null +++ b/src/services/mail.go @@ -0,0 +1,48 @@ +package services + +import ( + "bytes" + "fmt" + "html/template" + "log" + "os" + + "github.com/ascyii/qrank/src/config" + "github.com/ascyii/qrank/src/utils" + "github.com/gin-gonic/gin" + "gopkg.in/gomail.v2" +) + +func SendEmail(email string, token string) error { + // Parse the email body template + tmpl, err := template.ParseFiles("other/email.txt") + if err != nil { + panic(err) + } + + // Execute template with provided data + var body bytes.Buffer + if err := tmpl.Execute(&body, gin.H{"Token": token, "Name": utils.NameFromEmail(email)}); err != nil { + panic(err) + } + + if config.Config.Debug { + fmt.Println(body.String()) + return nil + } + + // Compose email + m := gomail.NewMessage() + m.SetHeader("From", os.Getenv("SMTP_MAIL")) + m.SetHeader("To", email) + m.SetHeader("Subject", "Registration/Login to QRank") + m.SetBody("text/plain", body.String()) + + // Dial and send + d := gomail.NewDialer(os.Getenv("SMTP_HOST"), 587, os.Getenv("SMTP_MAIL"), os.Getenv("SMTP_PASS")) + if err := d.DialAndSend(m); err != nil { + log.Fatalln(err) + return err + } + return nil +} diff --git a/src/session.go b/src/session.go deleted file mode 100644 index 5c5480a..0000000 --- a/src/session.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -// Middleware injects form data from session into context for templates -func SessionHandlerMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - session := sessions.Default(c) - raw := session.Get("form_data") - - form := map[string]string{} - if raw != nil { - if data, ok := raw.(string); ok { - _ = json.Unmarshal([]byte(data), &form) - } - // clear after first use - session.Delete("form_data") - _ = session.Save() - } - - // make available in context - c.Set("form", form) - - // Set message in context when there is one available in the session - message := session.Get("message") - c.Set("mes", message) - session.Delete("message") - - _ = session.Save() - c.Next() - } -} - -// SaveForm stores submitted POST form data into the session -func SaveForm(c *gin.Context) { - session := sessions.Default(c) - form := map[string]string{} - for key, values := range c.Request.PostForm { - if len(values) > 0 { - form[key] = values[0] - } - } - - if b, err := json.Marshal(form); err == nil { - session.Set("form_data", string(b)) - _ = session.Save() - } -} - -// SaveForm stores submitted POST form data into the session -func SetMessage(c *gin.Context, message string) { - session := sessions.Default(c) - session.Set("message", message) - _ = session.Save() -} - -func setSessionCookie(c *gin.Context, token string) { - // Very long-lived cookie for one year - maxAge := 365 * 24 * 60 * 60 - httpOnly := true - secure := strings.HasPrefix(baseURL, "https://") - sameSite := http.SameSiteLaxMode - c.SetCookie("session", token, maxAge, "/", cookieDomain, secure, httpOnly) - // Workaround to set SameSite explicitly via header - c.Header("Set-Cookie", (&http.Cookie{Name: "session", Value: token, Path: "/", Domain: cookieDomain, MaxAge: maxAge, Secure: secure, HttpOnly: httpOnly, SameSite: sameSite}).String()) -} - -func getSessionUser(c *gin.Context) *User { - cookie, err := c.Cookie("session") - if err != nil || cookie == "" { - return nil - } - var s Session - if err := db.Preload("User").Where("token = ?", cookie).First(&s).Error; err != nil { - return nil - } - return &s.User -} diff --git a/src/templates.go b/src/templates/templates.go similarity index 75% rename from src/templates.go rename to src/templates/templates.go index d2fc114..d3e9ed7 100644 --- a/src/templates.go +++ b/src/templates/templates.go @@ -1,4 +1,4 @@ -package main +package templates import ( "html/template" @@ -7,15 +7,13 @@ import ( "path/filepath" "time" + "github.com/ascyii/qrank/src/repository" + "github.com/ascyii/qrank/src/utils" "github.com/gin-gonic/gin" ) var tm *TemplateManager -func init() { - tm = NewTemplateManager("templates", "base", "html") -} - type TemplateManager struct { templates map[string]*template.Template funcs template.FuncMap @@ -24,9 +22,8 @@ type TemplateManager struct { dir string } -// NewTemplateManager initializes the manager and loads templates -func NewTemplateManager(dir string, base string, ext string) *TemplateManager { - tm := &TemplateManager{ +func init() { + tm = &TemplateManager{ templates: make(map[string]*template.Template), funcs: template.FuncMap{ // Attach functions to the templates here @@ -37,19 +34,16 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager { return a == b }, }, - base: base, - dir: dir, - ext: ext, + base: "base", + dir: "templates", + ext: "html", } - // Populate template manager - tm.LoadTemplates() - - return tm + LoadTemplates() } // LoadTemplates parses the base template with each view template in the directory -func (tm *TemplateManager) LoadTemplates() { +func LoadTemplates() { pattern := filepath.Join(tm.dir, "*."+tm.ext) files, err := filepath.Glob(pattern) if err != nil { @@ -62,7 +56,7 @@ func (tm *TemplateManager) LoadTemplates() { continue } - name := stripAfterDot(filepath.Base(file)) + name := utils.StripAfterDot(filepath.Base(file)) // Parse base + view template together tpl, err := template.New(name). @@ -77,8 +71,8 @@ func (tm *TemplateManager) LoadTemplates() { } // Render executes a template by name into the given context -func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error { - u := getSessionUser(c) +func Render(c *gin.Context, name string, data gin.H) error { + u := repository.FindUser(c) // Prefil the data for the render data["CurrentUser"] = u diff --git a/src/utils.go b/src/utils.go deleted file mode 100644 index cb5c372..0000000 --- a/src/utils.go +++ /dev/null @@ -1,202 +0,0 @@ -package main - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "errors" - "html/template" - "log" - - "net/http" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gopkg.in/gomail.v2" - - "github.com/skip2/go-qrcode" -) - -func mustRandToken(n int) string { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - panic(err) - } - return hex.EncodeToString(b) -} - -func now() time.Time { - return time.Now().UTC() -} - -var adjectives = []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} -var animals = []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} - -func defaultUsername() string { - // Inspired from IO games: adjective-animal-xx - a := adjectives[int(time.Now().UnixNano())%len(adjectives)] - an := animals[int(time.Now().UnixNano()/17)%len(animals)] - return a + "-" + an + "-" + strings.ToLower(mustRandToken(2)) -} - -func slugify(s string) string { - s = strings.ToLower(s) - s = strings.TrimSpace(s) - s = strings.ReplaceAll(s, " ", "-") - // keep alnum and - only - var b strings.Builder - for _, r := range s { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } - } - out := b.String() - if out == "" { - out = "user-" + mustRandToken(2) - } - return out -} - -func ensureUniqueUsernameAndSlug(u *User) error { - // try to keep username/slug unique by appending short token if needed - baseU := u.Username - baseS := slugify(u.Username) - for i := 0; i < 5; i++ { - candU := baseU - candS := baseS - if i > 0 { - suffix := "-" + mustRandToken(1) - candU = baseU + suffix - candS = baseS + suffix - } - var cnt int64 - db.Model(&User{}).Where("username = ?", candU).Count(&cnt) - if cnt == 0 { - db.Model(&User{}).Where("slug = ?", candS).Count(&cnt) - if cnt == 0 { - u.Username = candU - u.Slug = candS - return nil - } - } - } - return errors.New("cannot create unique username/slug") -} - -func userByEmail(email string) (*User, error) { - // Email must be lowercased and trimmed - var u User - tx := db.Where("email = ?", email).First(&u) - if tx.Error != nil { - return nil, tx.Error - } - return &u, nil -} - -func userByName(name string) (*User, error) { - var u User - tx := db.Where("username = ?", name).First(&u) - if tx.Error != nil { - return nil, tx.Error - } - return &u, nil -} - -func userBySlug(slug string) (*User, error) { - var u User - tx := db.Where("slug = ?", slug).First(&u) - if tx.Error != nil { - return nil, tx.Error - } - return &u, nil -} - -func atoiSafe(s string) int { - var n int - for _, r := range s { - if r < '0' || r > '9' { - return 0 - } - } - _, _ = fmtSscanf(s, "%d", &n) - return n -} - -// minimal sscanf to avoid fmt import just for one call -func fmtSscanf(s, _ string, a *int) (int, error) { - // Only supports "%d" - n := 0 - for i := 0; i < len(s); i++ { - c := s[i] - if c < '0' || c > '9' { - break - } - n = n*10 + int(c-'0') - } - *a = n - return 1, nil -} - -func stripAfterDot(s string) string { - if idx := strings.Index(s, "."); idx != -1 { - return s[:idx] - } - return s -} - -func nameFromEmail(email string) string { - parts := strings.SplitN(email, "@", 2) - if len(parts) > 0 { - return parts[0] - } - return "" -} - -func sendEmail(email string, token string) error { - // Parse the email body template - tmpl, err := template.ParseFiles("other/email.txt") - if err != nil { - panic(err) - } - - // Execute template with provided data - var body bytes.Buffer - if err := tmpl.Execute(&body, gin.H{"Token": token, "Name": nameFromEmail(email)}); err != nil { - panic(err) - } - - // Compose email - m := gomail.NewMessage() - m.SetHeader("From", os.Getenv("SMTP_MAIL")) - m.SetHeader("To", email) - m.SetHeader("Subject", "Registration/Login to QRank") - m.SetBody("text/plain", body.String()) - - // Dial and send - d := gomail.NewDialer(os.Getenv("SMTP_HOST"), 587, os.Getenv("SMTP_MAIL"), os.Getenv("SMTP_PASS")) - if err := d.DialAndSend(m); err != nil { - log.Fatalln(err) - return err - } - return nil -} - -func getQr(c *gin.Context) { - slug := c.Param("qrSlug") - var url string - if slug == "" { - url = "http://localhost:18765" - } else { - url = "http://localhost:18765/table/" + slug - } - - png, err := qrcode.Encode(url, qrcode.Medium, 256) - if err != nil { - c.String(http.StatusInternalServerError, "could not generate QR") - return - } - - c.Data(http.StatusOK, "image/png", png) -} diff --git a/src/utils/username.go b/src/utils/username.go new file mode 100644 index 0000000..82bc0f7 --- /dev/null +++ b/src/utils/username.go @@ -0,0 +1,36 @@ +package utils + +import ( + "strings" + "time" +) + +var ( + adjectives = []string{"swift", "brave", "mighty", "cheeky", "sneaky", "zippy", "bouncy", "crispy", "fuzzy", "spicy", "snappy", "jazzy", "spry", "bold", "witty"} + animals = []string{"otter", "panda", "falcon", "lynx", "badger", "tiger", "koala", "yak", "gecko", "eagle", "mamba", "fox", "yak", "whale", "rhino"} +) + +func DefaultUsername() string { + // Inspired from IO games: adjective-animal-xx + a := adjectives[int(time.Now().UnixNano())%len(adjectives)] + an := animals[int(time.Now().UnixNano()/17)%len(animals)] + return a + "-" + an + "-" + strings.ToLower(MustRandToken(2)) +} + +func Slugify(s string) string { + s = strings.ToLower(s) + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, " ", "-") + // keep alnum and - only + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + out := b.String() + if out == "" { + out = "user-" + MustRandToken(2) + } + return out +} diff --git a/src/utils/utils.go b/src/utils/utils.go new file mode 100644 index 0000000..c367831 --- /dev/null +++ b/src/utils/utils.go @@ -0,0 +1,42 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" + "math" + + "strings" + "time" +) + +func Now() time.Time { + return time.Now().UTC() +} + +func StripAfterDot(s string) string { + if idx := strings.Index(s, "."); idx != -1 { + return s[:idx] + } + return s +} + +func NameFromEmail(email string) string { + parts := strings.SplitN(email, "@", 2) + if len(parts) > 0 { + return parts[0] + } + return "" +} + +func RoundFloat(val float64, precision uint) float64 { + ratio := math.Pow(10, float64(precision)) + return math.Round(val*ratio) / ratio +} + +func MustRandToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +}