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] [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] [log]
time = false 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. The **easy** tracker for tabletop soccers with QR code integration.
Everything is still in development. Feel free to open issues and or pull requests. 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 ## 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. 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 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: // such as table soccer. This variant has:
// - No home/away asymmetry // - No home/away asymmetry
// - Margin of victory (MoV) factor using a robust bounded form // - Margin of victory (MoV) factor using a robust bounded form
@@ -8,14 +8,26 @@
// - Per-match delta cap // - Per-match delta cap
// - No time weighting and no explicit uncertainty modeling // - No time weighting and no explicit uncertainty modeling
package main package services
import ( import (
"errors" "errors"
"math" "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, Mu0: 1500,
Scale: 400, Scale: 400,
KNew: 48, KNew: 48,
@@ -26,45 +38,25 @@ var eloCfg = Config{
MaxGoals: 10, 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 { type Team struct {
Players []Player Players []*repository.GameUser
Score int Score int
AverageRating float64 AverageRating float32
} }
// Collect players by side func NewTeam(players []*repository.GameUser, score int) *Team {
type Player struct { var t1s float32 = 0
u *User
side string
dElo float64
}
func NewTeam(players []Player, score int) *Team {
var t1s float64 = 0
for _, player := range players { 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} return &Team{Players: players, Score: score, AverageRating: t1a}
} }
func GetNewElo(teams []*Team) error { func GetNewElo(t1, t2 *Team) error {
// Update the teams // Update the teams
t1 := teams[0]
t2 := teams[1]
if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals { if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals {
return errors.New("goals out of allowed range") return errors.New("goals out of allowed range")
} }
@@ -73,23 +65,24 @@ func GetNewElo(teams []*Team) error {
} }
// Expected score (BradleyTerry with logistic base-10) // Expected score (BradleyTerry with logistic base-10)
teams := []*Team{t1, t2}
for i, t := range teams { for i, t := range teams {
otherTeam := teams[1-i] otherTeam := teams[1-i]
for i, p := range t.Players { 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 { if err != nil {
return err return err
} }
t.Players[i].u.Elo = roundFloat(newRating, 1) t.Players[i].User.Elo = utils.RoundFloat(newRating, 1)
} }
} }
return nil 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) // Observed score (no draws)
var sA float64 var sA float32
if goalsA > goalsB { if goalsA > goalsB {
sA = 1.0 sA = 1.0
} else { } else {
@@ -112,7 +105,7 @@ func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64,
eA := expected(eloCfg.Scale, rA, rB) eA := expected(eloCfg.Scale, rA, rB)
mov := movFactor(rA, rB, diff) mov := movFactor(rA, rB, diff)
var Keff float64 var Keff float32
if gamesA <= eloCfg.BurnInGames { if gamesA <= eloCfg.BurnInGames {
Keff = eloCfg.KNew Keff = eloCfg.KNew
} else { } else {
@@ -131,13 +124,15 @@ func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64,
return rA + delta, nil return rA + delta, nil
} }
// expected returns the win probability for player A against B func expected(scale, rA, rB float32) float32 {
func expected(scale, rA, rB float64) float64 { return float32(
return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale)) 1.0 / (1.0 + math.Pow(10.0, float64(-(rA-rB)/scale))),
)
} }
// movFactor returns a bounded margin-of-victory multiplier. func movFactor(rA, rB float32, diff int) float32 {
func movFactor(rA, rB float64, diff int) float64 {
fd := float64(diff) 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 ( import (
"html/template" "html/template"
@@ -7,15 +7,13 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/ascyii/qrank/src/repository"
"github.com/ascyii/qrank/src/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
var tm *TemplateManager var tm *TemplateManager
func init() {
tm = NewTemplateManager("templates", "base", "html")
}
type TemplateManager struct { type TemplateManager struct {
templates map[string]*template.Template templates map[string]*template.Template
funcs template.FuncMap funcs template.FuncMap
@@ -24,9 +22,8 @@ type TemplateManager struct {
dir string dir string
} }
// NewTemplateManager initializes the manager and loads templates func init() {
func NewTemplateManager(dir string, base string, ext string) *TemplateManager { tm = &TemplateManager{
tm := &TemplateManager{
templates: make(map[string]*template.Template), templates: make(map[string]*template.Template),
funcs: template.FuncMap{ funcs: template.FuncMap{
// Attach functions to the templates here // Attach functions to the templates here
@@ -37,19 +34,16 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager {
return a == b return a == b
}, },
}, },
base: base, base: "base",
dir: dir, dir: "templates",
ext: ext, ext: "html",
} }
// Populate template manager LoadTemplates()
tm.LoadTemplates()
return tm
} }
// LoadTemplates parses the base template with each view template in the directory // 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) pattern := filepath.Join(tm.dir, "*."+tm.ext)
files, err := filepath.Glob(pattern) files, err := filepath.Glob(pattern)
if err != nil { if err != nil {
@@ -62,7 +56,7 @@ func (tm *TemplateManager) LoadTemplates() {
continue continue
} }
name := stripAfterDot(filepath.Base(file)) name := utils.StripAfterDot(filepath.Base(file))
// Parse base + view template together // Parse base + view template together
tpl, err := template.New(name). tpl, err := template.New(name).
@@ -77,8 +71,8 @@ func (tm *TemplateManager) LoadTemplates() {
} }
// Render executes a template by name into the given context // Render executes a template by name into the given context
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error { func Render(c *gin.Context, name string, data gin.H) error {
u := getSessionUser(c) u := repository.FindUser(c)
// Prefil the data for the render // Prefil the data for the render
data["CurrentUser"] = u 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)
}