Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 486cf3f0bd |
@@ -1,5 +1,5 @@
|
|||||||
[build]
|
[build]
|
||||||
cmd = "nix develop -c go build -o ./tmp/main ./cmd/server" # Only for nix users
|
cmd = "nix develop -c go build -o ./tmp/main ./src/..." # only for nix users
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
time = false
|
time = false
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -1,6 +0,0 @@
|
|||||||
######################
|
|
||||||
# Makefile for QRank #
|
|
||||||
######################
|
|
||||||
|
|
||||||
setup:
|
|
||||||
cp default.env .env
|
|
||||||
@@ -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/) and a custom templating package.
|
This application is build with [go](https://go.dev/) and [gin](https://gin-gonic.com/). Other languages like HTML and CSS are used.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Setup with `make setup`. Then configure the environment file.
|
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`.
|
||||||
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.
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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
10
default.env
@@ -1,10 +0,0 @@
|
|||||||
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 github.com/ascyii/qrank
|
module gitlab.gwdg.de/qrank/qrank
|
||||||
|
|
||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
|
|||||||
11
justfile
Normal file
11
justfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
######################
|
||||||
|
# Makefile for QRank #
|
||||||
|
######################
|
||||||
|
|
||||||
|
# Main target
|
||||||
|
run:
|
||||||
|
go run ./src
|
||||||
|
|
||||||
|
# Nix stuff
|
||||||
|
nix-run:
|
||||||
|
nix develop --command make run
|
||||||
42
src/auth.go
Normal file
42
src/auth.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// File elo implements a practical Elo rating update for head-to-head sports
|
// Package 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,26 +8,14 @@
|
|||||||
// - 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 services
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/ascyii/qrank/src/repository"
|
|
||||||
"github.com/ascyii/qrank/src/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var eloCfg = struct {
|
var eloCfg = Config{
|
||||||
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,
|
||||||
@@ -38,25 +26,45 @@ var eloCfg = struct {
|
|||||||
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 []*repository.GameUser
|
Players []Player
|
||||||
Score int
|
Score int
|
||||||
AverageRating float32
|
AverageRating float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTeam(players []*repository.GameUser, score int) *Team {
|
// Collect players by side
|
||||||
var t1s float32 = 0
|
type Player struct {
|
||||||
|
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 += float32(player.User.Elo)
|
t1s += float64(player.u.Elo)
|
||||||
|
|
||||||
}
|
}
|
||||||
t1a := t1s / float32(len(players))
|
t1a := t1s / float64(len(players))
|
||||||
|
|
||||||
return &Team{Players: players, Score: score, AverageRating: t1a}
|
return &Team{Players: players, Score: score, AverageRating: t1a}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetNewElo(t1, t2 *Team) error {
|
func GetNewElo(teams []*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")
|
||||||
}
|
}
|
||||||
@@ -65,24 +73,23 @@ func GetNewElo(t1, t2 *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.User.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.User.GameCount)
|
newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.Players[i].User.Elo = utils.RoundFloat(newRating, 1)
|
t.Players[i].u.Elo = roundFloat(newRating, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32, err error) {
|
func CalculateRating(rA, rB float64, goalsA, goalsB, gamesA int) (newrA float64, err error) {
|
||||||
// Observed score (no draws)
|
// Observed score (no draws)
|
||||||
var sA float32
|
var sA float64
|
||||||
if goalsA > goalsB {
|
if goalsA > goalsB {
|
||||||
sA = 1.0
|
sA = 1.0
|
||||||
} else {
|
} else {
|
||||||
@@ -105,7 +112,7 @@ func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32,
|
|||||||
eA := expected(eloCfg.Scale, rA, rB)
|
eA := expected(eloCfg.Scale, rA, rB)
|
||||||
mov := movFactor(rA, rB, diff)
|
mov := movFactor(rA, rB, diff)
|
||||||
|
|
||||||
var Keff float32
|
var Keff float64
|
||||||
if gamesA <= eloCfg.BurnInGames {
|
if gamesA <= eloCfg.BurnInGames {
|
||||||
Keff = eloCfg.KNew
|
Keff = eloCfg.KNew
|
||||||
} else {
|
} else {
|
||||||
@@ -124,15 +131,13 @@ func CalculateRating(rA, rB float32, goalsA, goalsB, gamesA int) (newrA float32,
|
|||||||
return rA + delta, nil
|
return rA + delta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func expected(scale, rA, rB float32) float32 {
|
// expected returns the win probability for player A against B
|
||||||
return float32(
|
func expected(scale, rA, rB float64) float64 {
|
||||||
1.0 / (1.0 + math.Pow(10.0, float64(-(rA-rB)/scale))),
|
return 1.0 / (1.0 + math.Pow(10.0, -(rA-rB)/scale))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func movFactor(rA, rB float32, diff int) float32 {
|
// movFactor returns a bounded margin-of-victory multiplier.
|
||||||
|
func movFactor(rA, rB float64, diff int) float64 {
|
||||||
fd := float64(diff)
|
fd := float64(diff)
|
||||||
return float32(
|
return math.Log(fd+1.0) * 2.2 / (math.Abs(rA-rB)*0.001 + 2.2)
|
||||||
math.Log(fd+1.0) * 2.2 / (math.Abs(float64(rA-rB))*0.001 + 2.2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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
Normal file
114
src/main.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
71
src/models.go
Normal file
71
src/models.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
Normal file
488
src/routes.go
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
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,48 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
85
src/session.go
Normal file
85
src/session.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 templates
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
@@ -7,13 +7,15 @@ 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
|
||||||
@@ -22,8 +24,9 @@ type TemplateManager struct {
|
|||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
// NewTemplateManager initializes the manager and loads templates
|
||||||
tm = &TemplateManager{
|
func NewTemplateManager(dir string, base string, ext string) *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
|
||||||
@@ -34,16 +37,19 @@ func init() {
|
|||||||
return a == b
|
return a == b
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
base: "base",
|
base: base,
|
||||||
dir: "templates",
|
dir: dir,
|
||||||
ext: "html",
|
ext: ext,
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadTemplates()
|
// Populate template manager
|
||||||
|
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 LoadTemplates() {
|
func (tm *TemplateManager) 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 {
|
||||||
@@ -56,7 +62,7 @@ func LoadTemplates() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name := utils.StripAfterDot(filepath.Base(file))
|
name := stripAfterDot(filepath.Base(file))
|
||||||
|
|
||||||
// Parse base + view template together
|
// Parse base + view template together
|
||||||
tpl, err := template.New(name).
|
tpl, err := template.New(name).
|
||||||
@@ -71,8 +77,8 @@ func LoadTemplates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render executes a template by name into the given context
|
// Render executes a template by name into the given context
|
||||||
func Render(c *gin.Context, name string, data gin.H) error {
|
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error {
|
||||||
u := repository.FindUser(c)
|
u := getSessionUser(c)
|
||||||
|
|
||||||
// Prefil the data for the render
|
// Prefil the data for the render
|
||||||
data["CurrentUser"] = u
|
data["CurrentUser"] = u
|
||||||
202
src/utils.go
Normal file
202
src/utils.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user