Compare commits

3 Commits

Author SHA1 Message Date
Jonas Hahn
b3310cb905 Fix bug with table presetting and add table couter and fix the join table 2025-09-18 21:49:28 +02:00
Jonas Hahn
15e71a1348 Changed to smaller float 2025-09-18 21:15:56 +02:00
Jonas Hahn
4168e92601 Full refactor of codebase 2025-09-18 16:51:57 +02:00
35 changed files with 1197 additions and 1078 deletions

View File

@@ -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

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
######################
# Makefile for QRank #
######################
setup:
cp default.env .env

View File

@@ -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.

39
cmd/server/main.go Normal file
View File

@@ -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)
}
}

10
default.env Normal file
View File

@@ -0,0 +1,10 @@
DEBUG=True
# Server configuration
APP_PORT=
APP_BASE_URL=
# For sending mail
SMTP_HOST=
SMTP_MAIL=
SMTP_PASS=

2
go.mod
View File

@@ -1,4 +1,4 @@
module gitlab.gwdg.de/qrank/qrank
module github.com/ascyii/qrank
go 1.24.5

View File

@@ -1,11 +0,0 @@
######################
# Makefile for QRank #
######################
# Main target
run:
go run ./src
# Nix stuff
nix-run:
nix develop --command make run

View File

@@ -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
}

45
src/config/config.go Normal file
View File

@@ -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
}
}

226
src/handlers/enter.go Normal file
View File

@@ -0,0 +1,226 @@
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) {
// Ensure current user
current := repository.FindUser(c)
if current == nil {
c.Redirect(http.StatusFound, "/login")
return
}
sess := sessions.Default(c)
tableSlug := sess.Get("TableSlug")
// Get the current table
var slug string
var t *repository.Table
if tableSlug != nil {
slug = tableSlug.(string)
if slug != "" {
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")) // A, B, None
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
p1 = resolveUser(p1h)
p2 = resolveUser(p2h)
p3 = resolveUser(p3h)
p4 = resolveUser(p4h)
if p1 == nil {
c.Redirect(http.StatusSeeOther, "/enter")
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 []float32
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 {
repository.GetDB().Save(p.User)
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
var err error
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")
}

61
src/handlers/history.go Normal file
View File

@@ -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})
}

14
src/handlers/index.go Normal file
View File

@@ -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)
}

View File

@@ -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,
})
}

51
src/handlers/login.go Normal file
View File

@@ -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(&lt).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")
}

34
src/handlers/magic.go Normal file
View File

@@ -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(&lt).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")
}

37
src/handlers/routes.go Normal file
View File

@@ -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)
}
}

109
src/handlers/table.go Normal file
View File

@@ -0,0 +1,109 @@
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 {
var tableObj repository.Table
repository.GetDB().Model(&repository.Table{}).Where("slug = ?", slug).First(&tableObj)
tableObj.Slug = slug
tableObj.GameCount++
// Pretend we got some values from somewhere else
var count int
tags := []string{"p1", "p2", "p3", "p4"}
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(tableObj)
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)
}

57
src/handlers/user.go Normal file
View File

@@ -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")
}

View File

@@ -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)
}
}

20
src/middleware/auth.go Normal file
View File

@@ -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()
}
}

37
src/middleware/session.go Normal file
View File

@@ -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()
}
}

View File

@@ -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"`
}

77
src/repository/crud.go Normal file
View File

@@ -0,0 +1,77 @@
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(),
Elo: 1500,
}
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
}

View File

@@ -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
}

32
src/repository/lookup.go Normal file
View File

@@ -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")
}

67
src/repository/models.go Normal file
View File

@@ -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 `gorm:"unique"`
Username string
Slug string `gorm:"unique"`
Elo float32
GameCount int
WinCount int
LossCount int
Games []Game `gorm:"many2many:game_users"`
LastLogin time.Time
Active bool
}
type Table struct {
gorm.Model
Name string
Slug string `gorm:"unique"`
GameCount int
}
type Game struct {
gorm.Model
TableID *uint
Table *Table
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
Game Game
UserID uint
User User
Side string `gorm:"size:1"`
DeltaElo float32
}
type LoginToken struct {
gorm.Model
Token string `gorm:"unique"`
Email string
ExpiresAt time.Time
}

42
src/repository/session.go Normal file
View File

@@ -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()
}

View File

@@ -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(&lt).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(&lt).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)
}

View File

@@ -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 float32
Scale float32 // KNew applies to players during burn-in (first BurnInGames matches).
KNew float32
KStd float32
BurnInGames int
RatingFloor float32
MaxPerMatchDelta float32
MaxGoals int
}{
Mu0: 1500,
Scale: 400,
KNew: 48,
@@ -26,45 +38,25 @@ 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
AverageRating float32
}
// Collect players by side
type Player struct {
u *User
side string
dElo float64
}
func NewTeam(players []Player, score int) *Team {
var t1s float64 = 0
func NewTeam(players []*repository.GameUser, score int) *Team {
var t1s float32 = 0
for _, player := range players {
t1s += float64(player.u.Elo)
t1s += float32(player.User.Elo)
}
t1a := t1s / float64(len(players))
t1a := t1s / float32(len(players))
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,23 +65,24 @@ func GetNewElo(teams []*Team) error {
}
// Expected score (BradleyTerry 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)
}
}
return nil
}
func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) {
func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32, err error) {
// Observed score (no draws)
var sA float64
var sA float32
if goalsA > goalsB {
sA = 1.0
} else {
@@ -112,7 +105,7 @@ func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64,
eA := expected(eloCfg.Scale, rA, rB)
mov := movFactor(rA, rB, diff)
var Keff float64
var Keff float32
if gamesA <= eloCfg.BurnInGames {
Keff = eloCfg.KNew
} else {
@@ -131,13 +124,15 @@ func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64,
return rA + delta, nil
}
// expected returns the win probability for player A against B
func expected(scale, rA, rB float64) float64 {
return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale))
func expected(scale, rA, rB float32) float32 {
return float32(
1.0 / (1.0 + math.Pow(10.0, float64(-(rA-rB)/scale))),
)
}
// movFactor returns a bounded margin-of-victory multiplier.
func movFactor(rA, rB float64, diff int) float64 {
func movFactor(rA, rB float32, diff int) float32 {
fd := float64(diff)
return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2)
return float32(
math.Log(fd+1.0) * 2.2 / (math.Abs(float64(rA-rB))*0.001 + 2.2),
)
}

48
src/services/mail.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

36
src/utils/username.go Normal file
View File

@@ -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
}

42
src/utils/utils.go Normal file
View File

@@ -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 float32, precision uint) float32 {
ratio := math.Pow(10, float64(precision))
return float32(math.Round(float64(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)
}