Full refactor of codebase
This commit is contained in:
@@ -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
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -2,10 +2,5 @@
|
|||||||
# Makefile for QRank #
|
# Makefile for QRank #
|
||||||
######################
|
######################
|
||||||
|
|
||||||
# Main target
|
setup:
|
||||||
run:
|
cp default.env .env
|
||||||
go run ./src
|
|
||||||
|
|
||||||
# Nix stuff
|
|
||||||
nix-run:
|
|
||||||
nix develop --command make run
|
|
||||||
|
|||||||
@@ -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
39
cmd/server/main.go
Normal 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
10
default.env
Normal 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
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module gitlab.gwdg.de/qrank/qrank
|
module github.com/ascyii/qrank
|
||||||
|
|
||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
|
|||||||
42
src/auth.go
42
src/auth.go
@@ -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
45
src/config/config.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/handlers/enter.go
Normal file
227
src/handlers/enter.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ascyii/qrank/src/repository"
|
||||||
|
"github.com/ascyii/qrank/src/services"
|
||||||
|
"github.com/ascyii/qrank/src/templates"
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getEnter(c *gin.Context) {
|
||||||
|
sess := sessions.Default(c)
|
||||||
|
tableSlug := sess.Get("TableSlug")
|
||||||
|
|
||||||
|
var slug string
|
||||||
|
if tableSlug != nil {
|
||||||
|
slug = tableSlug.(string)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if slug != "" {
|
||||||
|
|
||||||
|
var table *repository.Table
|
||||||
|
repository.GetDB().Model(&repository.Table{}).Where("slug = ?", slug).First(&table)
|
||||||
|
|
||||||
|
templates.Render(c, "enter", gin.H{"Table": table})
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
templates.Render(c, "enter", gin.H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postEnter(c *gin.Context) {
|
||||||
|
current := repository.FindUser(c)
|
||||||
|
if current == nil {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessions.Default(c)
|
||||||
|
tableSlug := sess.Get("TableSlug")
|
||||||
|
|
||||||
|
var slug string
|
||||||
|
var t *repository.Table
|
||||||
|
if tableSlug != nil {
|
||||||
|
slug = tableSlug.(string)
|
||||||
|
if slug != "" {
|
||||||
|
// Get the current table
|
||||||
|
repository.GetDB().Model(&repository.Table{}).Where("slug = ?", slug).First(&t)
|
||||||
|
} else {
|
||||||
|
t = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form
|
||||||
|
p1h := strings.TrimSpace(c.PostForm("p1"))
|
||||||
|
p2h := strings.TrimSpace(c.PostForm("p2"))
|
||||||
|
p3h := strings.TrimSpace(c.PostForm("p3"))
|
||||||
|
p4h := strings.TrimSpace(c.PostForm("p4"))
|
||||||
|
|
||||||
|
p1t := strings.TrimSpace(c.PostForm("team_p1"))
|
||||||
|
p2t := strings.TrimSpace(c.PostForm("team_p2"))
|
||||||
|
p3t := strings.TrimSpace(c.PostForm("team_p3"))
|
||||||
|
p4t := strings.TrimSpace(c.PostForm("team_p4"))
|
||||||
|
|
||||||
|
scoreA, _ := strconv.Atoi(c.PostForm("scoreA"))
|
||||||
|
scoreB, _ := strconv.Atoi(c.PostForm("scoreB"))
|
||||||
|
|
||||||
|
// Require a winner
|
||||||
|
if scoreA == scoreB {
|
||||||
|
repository.SaveForm(c)
|
||||||
|
repository.SetMessage(c, "There must be a winner")
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve players
|
||||||
|
resolveUser := func(handle string) *repository.User {
|
||||||
|
if handle == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u := repository.FindUser(handle)
|
||||||
|
if u != nil {
|
||||||
|
repository.SaveForm(c)
|
||||||
|
repository.SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle))
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
var p1, p2, p3, p4 *repository.User
|
||||||
|
|
||||||
|
// Always ensure A1 exists, fallback to current if empty
|
||||||
|
var err error
|
||||||
|
if p1 = resolveUser(p1h); p1 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p2 = resolveUser(p2h); p2 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p3 = resolveUser(p3h); p3 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p4 = resolveUser(p4h); p4 != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate raw teams
|
||||||
|
var players = []*repository.GameUser{}
|
||||||
|
var team1ps, team2ps []*repository.GameUser
|
||||||
|
|
||||||
|
users := []*repository.User{p1, p2, p3, p4}
|
||||||
|
sides := []string{p1t, p2t, p3t, p4t}
|
||||||
|
|
||||||
|
var playerbuf []float64
|
||||||
|
|
||||||
|
for i, u := range users {
|
||||||
|
if u != nil {
|
||||||
|
if sides[i] == "A" || sides[i] == "B" {
|
||||||
|
gu := &repository.GameUser{User: *u, Side: sides[i], DeltaElo: 0}
|
||||||
|
players = append(players, gu)
|
||||||
|
playerbuf = append(playerbuf, u.Elo)
|
||||||
|
if sides[i] == "A" {
|
||||||
|
team1ps = append(team1ps, gu)
|
||||||
|
} else {
|
||||||
|
team2ps = append(team2ps, gu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for at least one player on each side
|
||||||
|
if len(team1ps) == 0 || len(team2ps) == 0 {
|
||||||
|
repository.SaveForm(c)
|
||||||
|
repository.SetMessage(c, "There must be at least one player on each side")
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate users
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for i, u := range users {
|
||||||
|
if u != nil {
|
||||||
|
if seen[u.ID] {
|
||||||
|
repository.SaveForm(c)
|
||||||
|
repository.SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username))
|
||||||
|
c.Redirect(http.StatusSeeOther, "/enter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[u.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create game
|
||||||
|
var g repository.Game
|
||||||
|
|
||||||
|
if slug != "" {
|
||||||
|
g = repository.Game{
|
||||||
|
ScoreA: scoreA,
|
||||||
|
ScoreB: scoreB,
|
||||||
|
TableID: &t.ID,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
g = repository.Game{
|
||||||
|
ScoreA: scoreA,
|
||||||
|
ScoreB: scoreB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repository.GetDB().Create(&g).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "game create error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
team1 := services.NewTeam(team1ps, g.ScoreA)
|
||||||
|
team2 := services.NewTeam(team2ps, g.ScoreB)
|
||||||
|
|
||||||
|
// Set new elo for all players
|
||||||
|
services.GetNewElo(team1, team2)
|
||||||
|
|
||||||
|
var winningSide string
|
||||||
|
if g.ScoreA > g.ScoreB {
|
||||||
|
winningSide = "A"
|
||||||
|
} else {
|
||||||
|
winningSide = "B"
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, p := range players {
|
||||||
|
p.DeltaElo = players[i].User.Elo - playerbuf[i]
|
||||||
|
p.GameID = g.ID
|
||||||
|
p.UserID = p.User.ID
|
||||||
|
|
||||||
|
var updateString string
|
||||||
|
if p.Side == winningSide {
|
||||||
|
updateString = "win_count"
|
||||||
|
} else {
|
||||||
|
updateString = "loss_count"
|
||||||
|
|
||||||
|
}
|
||||||
|
var expr = gorm.Expr(updateString+" + ?", 1)
|
||||||
|
|
||||||
|
// Update win or loss
|
||||||
|
err = repository.GetDB().Model(&repository.User{}).
|
||||||
|
Where("id = ?", p.User.ID).
|
||||||
|
UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repository.GetDB().Create(p).Error; err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Failed to assign player")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Delete("TableSlug")
|
||||||
|
sess.Save()
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/history")
|
||||||
|
}
|
||||||
61
src/handlers/history.go
Normal file
61
src/handlers/history.go
Normal 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
14
src/handlers/index.go
Normal 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)
|
||||||
|
}
|
||||||
21
src/handlers/leaderboard.go
Normal file
21
src/handlers/leaderboard.go
Normal 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
51
src/handlers/login.go
Normal 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(<).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
34
src/handlers/magic.go
Normal 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(<).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
37
src/handlers/routes.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/handlers/table.go
Normal file
106
src/handlers/table.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/ascyii/qrank/src/repository"
|
||||||
|
"github.com/ascyii/qrank/src/templates"
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tables = make(map[string]*TableInfo)
|
||||||
|
|
||||||
|
// Memory table store
|
||||||
|
type TableInfo struct {
|
||||||
|
Players map[uint]*repository.User
|
||||||
|
PlayerCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTable(c *gin.Context) {
|
||||||
|
slug := c.Param("tableSlug")
|
||||||
|
u := repository.FindUser(c)
|
||||||
|
if tables[slug] == nil {
|
||||||
|
tables[slug] = &TableInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
table := tables[slug]
|
||||||
|
if tables[slug].Players == nil {
|
||||||
|
tables[slug].Players = make(map[uint]*repository.User)
|
||||||
|
}
|
||||||
|
|
||||||
|
if table.PlayerCount >= 4 {
|
||||||
|
templates.Render(c, "table_status", gin.H{"Full": "true", "CurrentSlug": slug, "Table": tables[slug]})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
table.Players[u.ID] = u
|
||||||
|
table.PlayerCount = len(table.Players)
|
||||||
|
|
||||||
|
templates.Render(c, "table_status", gin.H{"CurrentSlug": slug, "Table": tables[slug]})
|
||||||
|
}
|
||||||
|
|
||||||
|
func postTable(c *gin.Context) {
|
||||||
|
slug := c.Param("tableSlug")
|
||||||
|
table := tables[slug]
|
||||||
|
session := sessions.Default(c)
|
||||||
|
|
||||||
|
if len(table.Players) == 2 || len(table.Players) == 4 {
|
||||||
|
|
||||||
|
// Pretend we got some values from somewhere else
|
||||||
|
var count int
|
||||||
|
tags := []string{"a1", "b1", "a2", "b2"}
|
||||||
|
formData := make(map[string]string)
|
||||||
|
for _, value := range table.Players {
|
||||||
|
formData[tags[count]] = value.Username
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(formData); err == nil {
|
||||||
|
session.Set("form_data", string(b))
|
||||||
|
_ = session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Set("TableSlug", slug)
|
||||||
|
_ = session.Save()
|
||||||
|
|
||||||
|
// Reset the table
|
||||||
|
tables[slug] = &TableInfo{}
|
||||||
|
|
||||||
|
repository.GetDB().Save(&repository.Table{Slug: slug})
|
||||||
|
|
||||||
|
c.Redirect(302, "/enter")
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
repository.SetMessage(c, "Wrong amount of players!")
|
||||||
|
c.Redirect(302, "/table/"+slug)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func postTableReset(c *gin.Context) {
|
||||||
|
slug := c.Param("tableSlug")
|
||||||
|
tables[slug] = &TableInfo{}
|
||||||
|
repository.SetMessage(c, "Resettet the table!")
|
||||||
|
c.Redirect(302, "/table/"+slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get qr for table
|
||||||
|
func getQr(c *gin.Context) {
|
||||||
|
slug := c.Param("qrSlug")
|
||||||
|
var url string
|
||||||
|
if slug == "" {
|
||||||
|
url = "http://localhost:18765"
|
||||||
|
} else {
|
||||||
|
url = "http://localhost:18765/table/" + slug
|
||||||
|
}
|
||||||
|
|
||||||
|
png, err := qrcode.Encode(url, qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "could not generate QR")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "image/png", png)
|
||||||
|
}
|
||||||
57
src/handlers/user.go
Normal file
57
src/handlers/user.go
Normal 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")
|
||||||
|
}
|
||||||
114
src/main.go
114
src/main.go
@@ -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
20
src/middleware/auth.go
Normal 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
37
src/middleware/session.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
76
src/repository/crud.go
Normal file
76
src/repository/crud.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ascyii/qrank/src/utils"
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FindUser(ref any) *User {
|
||||||
|
// Session case
|
||||||
|
if c, ok := ref.(*gin.Context); ok {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
if uid := session.Get("user_id"); uid != nil {
|
||||||
|
var u User
|
||||||
|
if err := db.First(&u, uid.(uint)).Error; err == nil {
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String handle case
|
||||||
|
if h, ok := ref.(string); ok {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var u User
|
||||||
|
var err error
|
||||||
|
if strings.Contains(h, "@") {
|
||||||
|
err = db.Where("LOWER(email) = ?", strings.ToLower(h)).First(&u).Error
|
||||||
|
} else {
|
||||||
|
err = db.Where("username = ?", h).First(&u).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindOrCreateUserFromEmail(email string) *User {
|
||||||
|
var u User
|
||||||
|
// Try to find existing user
|
||||||
|
err := db.Where("email = ?", email).First(&u).Error // TODO: This makes bad logs
|
||||||
|
if err == nil {
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
log.Println("Find user:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
u = User{
|
||||||
|
Email: email,
|
||||||
|
Username: utils.DefaultUsername(),
|
||||||
|
}
|
||||||
|
if err := EnsureUniqueUsernameAndSlug(&u); err != nil {
|
||||||
|
log.Println("Ensure unique:", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&u).Error; err != nil {
|
||||||
|
log.Println("Create user:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &u
|
||||||
|
}
|
||||||
32
src/repository/database.go
Normal file
32
src/repository/database.go
Normal 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
32
src/repository/lookup.go
Normal 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
67
src/repository/models.go
Normal 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
|
||||||
|
Username string
|
||||||
|
Slug string
|
||||||
|
|
||||||
|
Elo float64
|
||||||
|
GameCount int
|
||||||
|
WinCount int
|
||||||
|
LossCount int
|
||||||
|
|
||||||
|
Games []Game `gorm:"many2many:game_user"`
|
||||||
|
LastLogin time.Time
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Table struct {
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
GameCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Game struct {
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
TableID *uint
|
||||||
|
Table *Table
|
||||||
|
|
||||||
|
Users []User `gorm:"many2many:game_user"`
|
||||||
|
|
||||||
|
ScoreA int
|
||||||
|
ScoreB int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join table between game and user with extra fields
|
||||||
|
type GameUser struct {
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
GameID uint
|
||||||
|
Game Game
|
||||||
|
|
||||||
|
UserID uint
|
||||||
|
User User
|
||||||
|
|
||||||
|
Side string `gorm:"size:1"`
|
||||||
|
DeltaElo float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginToken struct {
|
||||||
|
gorm.Model
|
||||||
|
|
||||||
|
Token string
|
||||||
|
Email string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
42
src/repository/session.go
Normal file
42
src/repository/session.go
Normal 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()
|
||||||
|
}
|
||||||
488
src/routes.go
488
src/routes.go
@@ -1,488 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getEnter(c *gin.Context) {
|
|
||||||
sess := sessions.Default(c)
|
|
||||||
tableSlug := sess.Get("TableSlug")
|
|
||||||
|
|
||||||
var slug string
|
|
||||||
if tableSlug != nil {
|
|
||||||
slug = tableSlug.(string)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if slug != "" {
|
|
||||||
|
|
||||||
var table *Table
|
|
||||||
db.Model(&Table{}).Where("slug = ?", slug).First(&table)
|
|
||||||
|
|
||||||
tm.Render(c, "enter", gin.H{"Table": table})
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
tm.Render(c, "enter", gin.H{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func postEnter(c *gin.Context) {
|
|
||||||
current := getSessionUser(c)
|
|
||||||
if current == nil {
|
|
||||||
c.Redirect(http.StatusFound, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sess := sessions.Default(c)
|
|
||||||
tableSlug := sess.Get("TableSlug")
|
|
||||||
|
|
||||||
var slug string
|
|
||||||
var t *Table
|
|
||||||
if tableSlug != nil {
|
|
||||||
slug = tableSlug.(string)
|
|
||||||
if slug != "" {
|
|
||||||
// Get the current table
|
|
||||||
db.Model(&Table{}).Where("slug = ?", slug).First(&t)
|
|
||||||
} else {
|
|
||||||
t = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse form
|
|
||||||
p1h := strings.TrimSpace(c.PostForm("p1"))
|
|
||||||
p2h := strings.TrimSpace(c.PostForm("p2"))
|
|
||||||
p3h := strings.TrimSpace(c.PostForm("p3"))
|
|
||||||
p4h := strings.TrimSpace(c.PostForm("p4"))
|
|
||||||
|
|
||||||
p1t := strings.TrimSpace(c.PostForm("team_p1"))
|
|
||||||
p2t := strings.TrimSpace(c.PostForm("team_p2"))
|
|
||||||
p3t := strings.TrimSpace(c.PostForm("team_p3"))
|
|
||||||
p4t := strings.TrimSpace(c.PostForm("team_p4"))
|
|
||||||
|
|
||||||
scoreA := atoiSafe(c.PostForm("scoreA"))
|
|
||||||
scoreB := atoiSafe(c.PostForm("scoreB"))
|
|
||||||
|
|
||||||
// Require a winner
|
|
||||||
if scoreA == scoreB {
|
|
||||||
SaveForm(c)
|
|
||||||
SetMessage(c, "There must be a winner")
|
|
||||||
c.Redirect(http.StatusSeeOther, "/enter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve players
|
|
||||||
resolveUser := func(handle string) (*User, error) {
|
|
||||||
if handle == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
u, err := findUserByHandle(handle)
|
|
||||||
if err != nil {
|
|
||||||
SaveForm(c)
|
|
||||||
SetMessage(c, fmt.Sprintf(`Player "%v" does not exist`, handle))
|
|
||||||
c.Redirect(http.StatusSeeOther, "/enter")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var p1, p2, p3, p4 *User
|
|
||||||
|
|
||||||
// Always ensure A1 exists, fallback to current if empty
|
|
||||||
var err error
|
|
||||||
if p1, err = resolveUser(p1h); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p2, err = resolveUser(p2h); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p3, err = resolveUser(p3h); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p4, err = resolveUser(p4h); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate raw teams
|
|
||||||
var players []Player
|
|
||||||
var team1ps, team2ps []Player
|
|
||||||
users := []*User{p1, p2, p3, p4}
|
|
||||||
sides := []string{p1t, p2t, p3t, p4t}
|
|
||||||
var playerbuf []float64
|
|
||||||
|
|
||||||
for i, u := range users {
|
|
||||||
if u != nil {
|
|
||||||
if sides[i] == "A" || sides[i] == "B" {
|
|
||||||
players = append(players, Player{u, sides[i], 0})
|
|
||||||
playerbuf = append(playerbuf, u.Elo)
|
|
||||||
if sides[i] == "A" {
|
|
||||||
team1ps = append(team1ps, Player{u, sides[i], 0})
|
|
||||||
} else {
|
|
||||||
team2ps = append(team2ps, Player{u, sides[i], 0})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for at least one player on each side
|
|
||||||
if len(team1ps) == 0 || len(team2ps) == 0 {
|
|
||||||
SaveForm(c)
|
|
||||||
SetMessage(c, "There must be at least one player on each side")
|
|
||||||
c.Redirect(http.StatusSeeOther, "/enter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate users
|
|
||||||
seen := map[uint]bool{}
|
|
||||||
for i, u := range users {
|
|
||||||
if u != nil {
|
|
||||||
if seen[u.ID] {
|
|
||||||
SaveForm(c)
|
|
||||||
SetMessage(c, fmt.Sprintf(`Player "%v" cannot play twice in one game`, users[i].Username))
|
|
||||||
c.Redirect(http.StatusSeeOther, "/enter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[u.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create game
|
|
||||||
var g Game
|
|
||||||
|
|
||||||
if slug != "" {
|
|
||||||
g = Game{
|
|
||||||
ScoreA: scoreA,
|
|
||||||
ScoreB: scoreB,
|
|
||||||
TableID: &t.ID,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
g = Game{
|
|
||||||
ScoreA: scoreA,
|
|
||||||
ScoreB: scoreB,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := db.Create(&g).Error; err != nil {
|
|
||||||
c.String(http.StatusInternalServerError, "game create error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
team1 := NewTeam(team1ps, g.ScoreA)
|
|
||||||
team2 := NewTeam(team2ps, g.ScoreB)
|
|
||||||
|
|
||||||
// Set new elo for all players
|
|
||||||
GetNewElo([]*Team{team1, team2})
|
|
||||||
|
|
||||||
var winningSide string
|
|
||||||
if g.ScoreA > g.ScoreB {
|
|
||||||
winningSide = "A"
|
|
||||||
} else {
|
|
||||||
winningSide = "B"
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, p := range players {
|
|
||||||
db.Save(p.u)
|
|
||||||
p.dElo = players[i].u.Elo - playerbuf[i]
|
|
||||||
|
|
||||||
var updateString string
|
|
||||||
if p.side == winningSide {
|
|
||||||
updateString = "win_count"
|
|
||||||
} else {
|
|
||||||
updateString = "loss_count"
|
|
||||||
|
|
||||||
}
|
|
||||||
var expr = gorm.Expr(updateString+" + ?", 1)
|
|
||||||
|
|
||||||
// Update win or loss
|
|
||||||
err = db.Model(&User{}).
|
|
||||||
Where("id = ?", p.u.ID).
|
|
||||||
UpdateColumn(updateString, expr).UpdateColumn("game_count", gorm.Expr("game_count + ?", 1)).Error
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&GameUser{GameID: g.ID, UserID: p.u.ID, Side: p.side, DeltaElo: p.dElo}).Error; err != nil {
|
|
||||||
c.String(http.StatusInternalServerError, "failed to assign player")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sess.Delete("TableSlug")
|
|
||||||
sess.Save()
|
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "/history")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getIndex(c *gin.Context) {
|
|
||||||
if u := getSessionUser(c); u != nil {
|
|
||||||
c.Redirect(302, "/enter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
getLogin(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundFloat(val float64, precision uint) float64 {
|
|
||||||
ratio := math.Pow(10, float64(precision))
|
|
||||||
return math.Round(val*ratio) / ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHistory(c *gin.Context) {
|
|
||||||
// Load recent games
|
|
||||||
var games []Game
|
|
||||||
db.Preload("Table").Order("created_at desc").Find(&games)
|
|
||||||
|
|
||||||
type UserElo struct {
|
|
||||||
User
|
|
||||||
|
|
||||||
DeltaElo string
|
|
||||||
Color string
|
|
||||||
}
|
|
||||||
|
|
||||||
type GRow struct {
|
|
||||||
Game
|
|
||||||
PlayersA []UserElo
|
|
||||||
PlayersB []UserElo
|
|
||||||
WinnerIsA bool
|
|
||||||
}
|
|
||||||
var rows []GRow
|
|
||||||
for _, g := range games {
|
|
||||||
var gps []GameUser
|
|
||||||
db.Model(&GameUser{}).Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
|
||||||
var a, b []UserElo
|
|
||||||
for _, gp := range gps {
|
|
||||||
var eloColor, eloFloatString string
|
|
||||||
eloFloat := roundFloat(gp.DeltaElo, 2)
|
|
||||||
|
|
||||||
if eloFloat > 0 {
|
|
||||||
eloColor = "green"
|
|
||||||
eloFloatString = fmt.Sprintf("+%.2f", eloFloat)
|
|
||||||
} else if eloFloat < 0 {
|
|
||||||
eloColor = "red"
|
|
||||||
eloFloatString = fmt.Sprintf("%.2f", eloFloat)
|
|
||||||
} else {
|
|
||||||
eloColor = "black"
|
|
||||||
eloFloatString = fmt.Sprintf("%.2f", eloFloat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if gp.Side == "A" {
|
|
||||||
a = append(a, UserElo{gp.User, eloFloatString, eloColor})
|
|
||||||
} else {
|
|
||||||
b = append(b, UserElo{gp.User, eloFloatString, eloColor})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b, WinnerIsA: g.ScoreA > g.ScoreB})
|
|
||||||
|
|
||||||
}
|
|
||||||
tm.Render(c, "history", gin.H{"Games": rows})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLeaderboard(c *gin.Context) {
|
|
||||||
var users []User
|
|
||||||
if err := db.Order("elo DESC").Find(&users).Error; err != nil {
|
|
||||||
c.String(http.StatusInternalServerError, "Error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tm.Render(c, "leaderboard", gin.H{
|
|
||||||
"Users": users,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUserView(c *gin.Context) {
|
|
||||||
slug := c.Param("userSlug")
|
|
||||||
u, err := userBySlug(slug)
|
|
||||||
if err != nil {
|
|
||||||
c.String(404, "user not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if own user
|
|
||||||
var own bool
|
|
||||||
if slug == u.Slug {
|
|
||||||
own = true
|
|
||||||
}
|
|
||||||
|
|
||||||
tm.Render(c, "user", gin.H{"User": u, "Own": own})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMe(c *gin.Context) {
|
|
||||||
u := getSessionUser(c)
|
|
||||||
tm.Render(c, "user", gin.H{"User": u, "Own": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func postMe(c *gin.Context) {
|
|
||||||
cu := getSessionUser(c)
|
|
||||||
if cu == nil {
|
|
||||||
c.Redirect(302, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newU := strings.TrimSpace(c.PostForm("username"))
|
|
||||||
if newU == "" || newU == cu.Username {
|
|
||||||
c.Redirect(302, "/me")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update username and slug
|
|
||||||
cu.Username = newU
|
|
||||||
cu.Slug = slugify(newU)
|
|
||||||
if err := ensureUniqueUsernameAndSlug(cu); err != nil {
|
|
||||||
log.Println("unique username error:", err)
|
|
||||||
}
|
|
||||||
if err := db.Save(cu).Error; err != nil {
|
|
||||||
log.Println("save user:", err)
|
|
||||||
}
|
|
||||||
c.Redirect(302, "/me")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLogin(c *gin.Context) {
|
|
||||||
tm.Render(c, "login", gin.H{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func postLogin(c *gin.Context) {
|
|
||||||
email := strings.ToLower(strings.TrimSpace(c.PostForm("email")))
|
|
||||||
if email == "" {
|
|
||||||
c.Redirect(302, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensure user exists
|
|
||||||
var u User
|
|
||||||
tx := db.Where("email = ?", email).First(&u)
|
|
||||||
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
|
||||||
u = User{Email: email, Username: defaultUsername()}
|
|
||||||
if err := ensureUniqueUsernameAndSlug(&u); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&u).Error; err != nil {
|
|
||||||
log.Println("create user:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// create token valid 30 days
|
|
||||||
token := mustRandToken(24)
|
|
||||||
lt := LoginToken{Token: token, Email: email, ExpiresAt: now().Add(30 * 24 * time.Hour)}
|
|
||||||
if err := db.Create(<).Error; err != nil {
|
|
||||||
log.Println("create token:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
link := strings.TrimRight(baseURL, "/") + "/magic?token=" + token
|
|
||||||
log.Printf("[MAGIC LINK] %s for %s (valid until %s)\n", link, email, lt.ExpiresAt.Format(time.RFC3339))
|
|
||||||
|
|
||||||
if err := sendEmail(email, link); err != nil {
|
|
||||||
c.Status(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
tm.Render(c, "sent", gin.H{"Email": email})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMagic(c *gin.Context) {
|
|
||||||
token := c.Query("token")
|
|
||||||
if token == "" {
|
|
||||||
c.Redirect(302, "/login")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var lt LoginToken
|
|
||||||
if err := db.Where("token = ? AND expires_at > ?", token, now()).First(<).Error; err != nil {
|
|
||||||
c.String(400, "Invalid or expired token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// find or create user by email again (in case)
|
|
||||||
u, err := userByEmail(lt.Email)
|
|
||||||
if err != nil {
|
|
||||||
c.String(500, "user lookup error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// create session
|
|
||||||
sessTok := mustRandToken(24)
|
|
||||||
if err := db.Create(&Session{Token: sessTok, UserID: u.ID}).Error; err != nil {
|
|
||||||
c.String(500, "session error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSessionCookie(c, sessTok)
|
|
||||||
|
|
||||||
c.Redirect(302, "/enter")
|
|
||||||
}
|
|
||||||
|
|
||||||
type TableInfo struct {
|
|
||||||
Players map[uint]*User
|
|
||||||
PlayerCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
var tables = make(map[string]*TableInfo)
|
|
||||||
|
|
||||||
func getTable(c *gin.Context) {
|
|
||||||
slug := c.Param("tableSlug")
|
|
||||||
u := getSessionUser(c)
|
|
||||||
if tables[slug] == nil {
|
|
||||||
tables[slug] = &TableInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
table := tables[slug]
|
|
||||||
if tables[slug].Players == nil {
|
|
||||||
tables[slug].Players = make(map[uint]*User)
|
|
||||||
}
|
|
||||||
|
|
||||||
if table.PlayerCount >= 4 {
|
|
||||||
tm.Render(c, "table_status", gin.H{"Full": "true", "CurrentSlug": slug, "Table": tables[slug]})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
table.Players[u.ID] = u
|
|
||||||
table.PlayerCount = len(table.Players)
|
|
||||||
|
|
||||||
tm.Render(c, "table_status", gin.H{"CurrentSlug": slug, "Table": tables[slug]})
|
|
||||||
}
|
|
||||||
|
|
||||||
func postTable(c *gin.Context) {
|
|
||||||
slug := c.Param("tableSlug")
|
|
||||||
table := tables[slug]
|
|
||||||
session := sessions.Default(c)
|
|
||||||
|
|
||||||
if len(table.Players) == 2 || len(table.Players) == 4 {
|
|
||||||
|
|
||||||
// Pretend we got some values from somewhere else
|
|
||||||
var count int
|
|
||||||
tags := []string{"a1", "b1", "a2", "b2"}
|
|
||||||
formData := make(map[string]string)
|
|
||||||
for _, value := range table.Players {
|
|
||||||
formData[tags[count]] = value.Username
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if b, err := json.Marshal(formData); err == nil {
|
|
||||||
session.Set("form_data", string(b))
|
|
||||||
_ = session.Save()
|
|
||||||
}
|
|
||||||
|
|
||||||
session.Set("TableSlug", slug)
|
|
||||||
_ = session.Save()
|
|
||||||
|
|
||||||
// Reset the table
|
|
||||||
tables[slug] = &TableInfo{}
|
|
||||||
|
|
||||||
db.Save(&Table{Slug: slug})
|
|
||||||
|
|
||||||
c.Redirect(302, "/enter")
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
SetMessage(c, "Wrong amount of players!")
|
|
||||||
c.Redirect(302, "/table/"+slug)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func postTableReset(c *gin.Context) {
|
|
||||||
slug := c.Param("tableSlug")
|
|
||||||
tables[slug] = &TableInfo{}
|
|
||||||
SetMessage(c, "Resettet the table!")
|
|
||||||
c.Redirect(302, "/table/"+slug)
|
|
||||||
}
|
|
||||||
@@ -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 float64
|
||||||
|
Scale float64 // KNew applies to players during burn-in (first BurnInGames matches).
|
||||||
|
KNew float64
|
||||||
|
KStd float64
|
||||||
|
BurnInGames int
|
||||||
|
RatingFloor float64
|
||||||
|
MaxPerMatchDelta float64
|
||||||
|
MaxGoals int
|
||||||
|
}{
|
||||||
Mu0: 1500,
|
Mu0: 1500,
|
||||||
Scale: 400,
|
Scale: 400,
|
||||||
KNew: 48,
|
KNew: 48,
|
||||||
@@ -26,34 +38,16 @@ 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 float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect players by side
|
func NewTeam(players []*repository.GameUser, score int) *Team {
|
||||||
type Player struct {
|
|
||||||
u *User
|
|
||||||
side string
|
|
||||||
dElo float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTeam(players []Player, score int) *Team {
|
|
||||||
var t1s float64 = 0
|
var t1s float64 = 0
|
||||||
for _, player := range players {
|
for _, player := range players {
|
||||||
t1s += float64(player.u.Elo)
|
t1s += float64(player.User.Elo)
|
||||||
|
|
||||||
}
|
}
|
||||||
t1a := t1s / float64(len(players))
|
t1a := t1s / float64(len(players))
|
||||||
@@ -61,10 +55,8 @@ func NewTeam(players []Player, score int) *Team {
|
|||||||
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,14 +65,15 @@ func GetNewElo(teams []*Team) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expected score (Bradley–Terry with logistic base-10)
|
// Expected score (Bradley–Terry 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
48
src/services/mail.go
Normal file
48
src/services/mail.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
202
src/utils.go
202
src/utils.go
@@ -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
36
src/utils/username.go
Normal 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
42
src/utils/utils.go
Normal 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 float64, precision uint) float64 {
|
||||||
|
ratio := math.Pow(10, float64(precision))
|
||||||
|
return math.Round(val*ratio) / ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustRandToken(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user