Full refactor of codebase
This commit is contained in:
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:
|
||||
// - No home/away asymmetry
|
||||
// - Margin of victory (MoV) factor using a robust bounded form
|
||||
@@ -8,14 +8,26 @@
|
||||
// - Per-match delta cap
|
||||
// - No time weighting and no explicit uncertainty modeling
|
||||
|
||||
package main
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"github.com/ascyii/qrank/src/repository"
|
||||
"github.com/ascyii/qrank/src/utils"
|
||||
)
|
||||
|
||||
var eloCfg = Config{
|
||||
var eloCfg = struct {
|
||||
Mu0 float64
|
||||
Scale float64 // KNew applies to players during burn-in (first BurnInGames matches).
|
||||
KNew float64
|
||||
KStd float64
|
||||
BurnInGames int
|
||||
RatingFloor float64
|
||||
MaxPerMatchDelta float64
|
||||
MaxGoals int
|
||||
}{
|
||||
Mu0: 1500,
|
||||
Scale: 400,
|
||||
KNew: 48,
|
||||
@@ -26,34 +38,16 @@ var eloCfg = Config{
|
||||
MaxGoals: 10,
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Mu0 float64
|
||||
Scale float64 // KNew applies to players during burn-in (first BurnInGames matches).
|
||||
KNew float64
|
||||
KStd float64
|
||||
BurnInGames int
|
||||
RatingFloor float64
|
||||
MaxPerMatchDelta float64
|
||||
MaxGoals int
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
Players []Player
|
||||
Players []*repository.GameUser
|
||||
Score int
|
||||
AverageRating float64
|
||||
}
|
||||
|
||||
// Collect players by side
|
||||
type Player struct {
|
||||
u *User
|
||||
side string
|
||||
dElo float64
|
||||
}
|
||||
|
||||
func NewTeam(players []Player, score int) *Team {
|
||||
func NewTeam(players []*repository.GameUser, score int) *Team {
|
||||
var t1s float64 = 0
|
||||
for _, player := range players {
|
||||
t1s += float64(player.u.Elo)
|
||||
t1s += float64(player.User.Elo)
|
||||
|
||||
}
|
||||
t1a := t1s / float64(len(players))
|
||||
@@ -61,10 +55,8 @@ func NewTeam(players []Player, score int) *Team {
|
||||
return &Team{Players: players, Score: score, AverageRating: t1a}
|
||||
}
|
||||
|
||||
func GetNewElo(teams []*Team) error {
|
||||
func GetNewElo(t1, t2 *Team) error {
|
||||
// Update the teams
|
||||
t1 := teams[0]
|
||||
t2 := teams[1]
|
||||
if t2.Score < 0 || t1.Score < 0 || t2.Score > eloCfg.MaxGoals || t1.Score > eloCfg.MaxGoals {
|
||||
return errors.New("goals out of allowed range")
|
||||
}
|
||||
@@ -73,14 +65,15 @@ func GetNewElo(teams []*Team) error {
|
||||
}
|
||||
|
||||
// Expected score (Bradley–Terry with logistic base-10)
|
||||
teams := []*Team{t1, t2}
|
||||
for i, t := range teams {
|
||||
otherTeam := teams[1-i]
|
||||
for i, p := range t.Players {
|
||||
newRating, err := CalculateRating(p.u.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.u.GameCount)
|
||||
newRating, err := CalculateRating(p.User.Elo, otherTeam.AverageRating, t.Score, otherTeam.Score, p.User.GameCount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Players[i].u.Elo = roundFloat(newRating, 1)
|
||||
t.Players[i].User.Elo = utils.RoundFloat(newRating, 1)
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
"html/template"
|
||||
@@ -7,15 +7,13 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/ascyii/qrank/src/repository"
|
||||
"github.com/ascyii/qrank/src/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var tm *TemplateManager
|
||||
|
||||
func init() {
|
||||
tm = NewTemplateManager("templates", "base", "html")
|
||||
}
|
||||
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
funcs template.FuncMap
|
||||
@@ -24,9 +22,8 @@ type TemplateManager struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewTemplateManager initializes the manager and loads templates
|
||||
func NewTemplateManager(dir string, base string, ext string) *TemplateManager {
|
||||
tm := &TemplateManager{
|
||||
func init() {
|
||||
tm = &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcs: template.FuncMap{
|
||||
// Attach functions to the templates here
|
||||
@@ -37,19 +34,16 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager {
|
||||
return a == b
|
||||
},
|
||||
},
|
||||
base: base,
|
||||
dir: dir,
|
||||
ext: ext,
|
||||
base: "base",
|
||||
dir: "templates",
|
||||
ext: "html",
|
||||
}
|
||||
|
||||
// Populate template manager
|
||||
tm.LoadTemplates()
|
||||
|
||||
return tm
|
||||
LoadTemplates()
|
||||
}
|
||||
|
||||
// LoadTemplates parses the base template with each view template in the directory
|
||||
func (tm *TemplateManager) LoadTemplates() {
|
||||
func LoadTemplates() {
|
||||
pattern := filepath.Join(tm.dir, "*."+tm.ext)
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
@@ -62,7 +56,7 @@ func (tm *TemplateManager) LoadTemplates() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := stripAfterDot(filepath.Base(file))
|
||||
name := utils.StripAfterDot(filepath.Base(file))
|
||||
|
||||
// Parse base + view template together
|
||||
tpl, err := template.New(name).
|
||||
@@ -77,8 +71,8 @@ func (tm *TemplateManager) LoadTemplates() {
|
||||
}
|
||||
|
||||
// Render executes a template by name into the given context
|
||||
func (tm *TemplateManager) Render(c *gin.Context, name string, data gin.H) error {
|
||||
u := getSessionUser(c)
|
||||
func Render(c *gin.Context, name string, data gin.H) error {
|
||||
u := repository.FindUser(c)
|
||||
|
||||
// Prefil the data for the render
|
||||
data["CurrentUser"] = u
|
||||
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