First not working app skeleton with nix support and dependency setup and generated logo not final structure
This commit is contained in:
134
src/app.go
Normal file
134
src/app.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package src
|
||||
|
||||
// socqr-mvp: single-binary Go + Gin app with email-magic-link auth (debug links in logs),
|
||||
// SQLite (switchable to Postgres), and four views: user view, enter scores, history, leaderboard.
|
||||
//
|
||||
// Quick start:
|
||||
// go mod init socqr
|
||||
// go get github.com/gin-gonic/gin gorm.io/gorm gorm.io/driver/sqlite gorm.io/driver/postgres
|
||||
// go run .
|
||||
//
|
||||
// Env vars (optional):
|
||||
// DATABASE_URL="postgres://user:pass@host:5432/dbname?sslmode=disable" // if set, uses Postgres; else SQLite file socqr.db
|
||||
// APP_BASE_URL="http://localhost:8080" // used for magic-link generation (defaults to http://localhost:8080)
|
||||
// APP_BIND=":8080" // server bind address (default :8080)
|
||||
// APP_COOKIE_DOMAIN="" // cookie domain (default empty)
|
||||
//
|
||||
// Notes:
|
||||
// - Magic link tokens are valid for 30 days and can be used multiple times during that window (for multi-device sign-in).
|
||||
// - Session cookie is long-lived (~10 years). Delete cookie to sign out.
|
||||
// - Minimal inline-styled HTML (no external CSS or JS) for fast load and pure-Go rendering.
|
||||
// - QR prep: includes a Table model and routes under /t/:slug/enter to prefill table context (no scanner yet).
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ===================== Globals =====================
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
baseURL string
|
||||
cookieDomain string
|
||||
)
|
||||
|
||||
const (
|
||||
PORT = "18765"
|
||||
)
|
||||
|
||||
// ===================== Templates =====================
|
||||
|
||||
var tpl *template.Template
|
||||
|
||||
func init() {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if filepath.Base(wd) == "src" {
|
||||
wd = filepath.Join(wd, "..") // adjust if running from src dir
|
||||
}
|
||||
path := filepath.Join(wd, "templates", "*.html")
|
||||
|
||||
tpl = template.Must(template.New("").Funcs(template.FuncMap{
|
||||
"fmtTime": func(t time.Time) string { return t.Local().Format("1000-01-01 10:10") },
|
||||
}).ParseGlob(path))
|
||||
}
|
||||
|
||||
// ===================== Main =====================
|
||||
|
||||
func App() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
baseURL = os.Getenv("APP_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:" + PORT
|
||||
}
|
||||
cookieDomain = os.Getenv("APP_COOKIE_DOMAIN")
|
||||
|
||||
// DB connect
|
||||
var err error
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn != "" {
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("postgres connect:", err)
|
||||
}
|
||||
log.Println("using Postgres")
|
||||
} else {
|
||||
db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("sqlite connect:", err)
|
||||
}
|
||||
log.Println("using SQLite qrank.db")
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil {
|
||||
log.Fatal("migrate:", err)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 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)
|
||||
|
||||
// Authenticated routes
|
||||
r.GET("/enter", requireAuth(), getEnter)
|
||||
r.POST("/enter", requireAuth(), postEnter)
|
||||
|
||||
// QR-prepped table routes
|
||||
r.GET("/t/:tslug/enter", requireAuth(), getEnter)
|
||||
r.POST("/t/:tslug/enter", requireAuth(), postEnter)
|
||||
|
||||
r.GET("/history", requireAuth(), getHistory)
|
||||
r.GET("/leaderboard", requireAuth(), getLeaderboard)
|
||||
|
||||
r.GET("/u/:slug", requireAuth(), getUserView)
|
||||
r.GET("/me", requireAuth(), getMe)
|
||||
r.POST("/me", requireAuth(), postMe)
|
||||
|
||||
bind := os.Getenv("APP_BIND")
|
||||
if bind == "" {
|
||||
bind = ":" + PORT
|
||||
}
|
||||
log.Println("listening on", bind, "base:", baseURL)
|
||||
if err := r.Run(bind); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
78
src/auth.go
Normal file
78
src/auth.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func requireAuth() 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 setSessionCookie(c *gin.Context, token string) {
|
||||
// Very long-lived cookie (~10 years)
|
||||
maxAge := 10 * 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 findOrCreateUserByHandle(handle string) (*User, error) {
|
||||
h := strings.TrimSpace(handle)
|
||||
if h == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if strings.Contains(h, "@") { // email
|
||||
if u, err := userByEmail(strings.ToLower(h)); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
// create
|
||||
u := &User{Email: strings.ToLower(h), Username: defaultUsername()}
|
||||
if err := ensureUniqueUsernameAndSlug(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
// username
|
||||
var u User
|
||||
if err := db.Where("username = ?", h).First(&u).Error; err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
// If not found, create placeholder user with random email-like token
|
||||
u = User{Email: mustRandToken(3) + "+placeholder@local", Username: h, Slug: slugify(h)}
|
||||
if err := ensureUniqueUsernameAndSlug(&u); err != nil { /* best-effort */
|
||||
}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
118
src/auth_test.go
Normal file
118
src/auth_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// setupTestDB initializes an in-memory SQLite DB for tests
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
d, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
if err := d.AutoMigrate(&User{}, &Session{}); err != nil {
|
||||
t.Fatalf("failed to migrate test db: %v", err)
|
||||
}
|
||||
db = d // override global
|
||||
return d
|
||||
}
|
||||
|
||||
// TestFindOrCreateUserByHandle_Email
|
||||
func TestFindOrCreateUserByHandle_Email(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
u, err := findOrCreateUserByHandle("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.Email != "test@example.com" {
|
||||
t.Errorf("expected email test@example.com, got %s", u.Email)
|
||||
}
|
||||
|
||||
// Should return same user if called again
|
||||
u2, err := findOrCreateUserByHandle("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.ID != u2.ID {
|
||||
t.Errorf("expected same user ID, got %d and %d", u.ID, u2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindOrCreateUserByHandle_Username
|
||||
func TestFindOrCreateUserByHandle_Username(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
u, err := findOrCreateUserByHandle("PlayerOne")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(u.Email, "@local") {
|
||||
t.Errorf("expected placeholder email, got %s", u.Email)
|
||||
}
|
||||
if u.Username != "PlayerOne" {
|
||||
t.Errorf("expected username PlayerOne, got %s", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSessionUserAndRequireAuth
|
||||
func TestGetSessionUserAndRequireAuth(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
// Create a user + session
|
||||
user := &User{Email: "auth@example.com", Username: "authuser"}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
session := &Session{Token: "testtoken", UserID: user.ID}
|
||||
if err := db.Create(session).Error; err != nil {
|
||||
t.Fatalf("failed to create session: %v", err)
|
||||
}
|
||||
|
||||
// Make a Gin context with session cookie
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "testtoken"})
|
||||
c.Request = req
|
||||
|
||||
// Should find user
|
||||
u := getSessionUser(c)
|
||||
if u == nil || u.Email != "auth@example.com" {
|
||||
t.Errorf("expected user auth@example.com, got %+v", u)
|
||||
}
|
||||
|
||||
// Test requireAuth middleware with valid session
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
req, _ = http.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "testtoken"})
|
||||
c.Request = req
|
||||
c.Next() // prepare pipeline
|
||||
mw := requireAuth()
|
||||
mw(c)
|
||||
|
||||
if c.IsAborted() {
|
||||
t.Errorf("expected request to pass middleware, but it aborted")
|
||||
}
|
||||
|
||||
// Test requireAuth middleware with no cookie
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
req, _ = http.NewRequest("GET", "/", nil)
|
||||
c.Request = req
|
||||
mw = requireAuth()
|
||||
mw(c)
|
||||
|
||||
if !c.IsAborted() {
|
||||
t.Errorf("expected request to abort when no session, but it passed")
|
||||
}
|
||||
}
|
||||
348
src/handlers.go
Normal file
348
src/handlers.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ctxData map[string]any
|
||||
|
||||
func getIndex(c *gin.Context) {
|
||||
if getSessionUser(c) != nil {
|
||||
c.Redirect(302, "/enter")
|
||||
return
|
||||
}
|
||||
getLogin(c)
|
||||
}
|
||||
|
||||
func getLogin(c *gin.Context) {
|
||||
render(c, "login", ctxData{})
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
render(c, "sent", ctxData{"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")
|
||||
}
|
||||
|
||||
func getEnter(c *gin.Context) {
|
||||
var table *Table
|
||||
if slug := c.Param("tslug"); slug != "" {
|
||||
var t Table
|
||||
if err := db.Where("slug = ?", slug).First(&t).Error; err == nil {
|
||||
table = &t
|
||||
}
|
||||
}
|
||||
post := "/enter"
|
||||
if table != nil {
|
||||
post = "/t/" + table.Slug + "/enter"
|
||||
}
|
||||
render(c, "enter", ctxData{"PostAction": post, "Table": table})
|
||||
}
|
||||
|
||||
func postEnter(c *gin.Context) {
|
||||
current := getSessionUser(c)
|
||||
if current == nil {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
a1h := c.PostForm("a1")
|
||||
a2h := c.PostForm("a2")
|
||||
b1h := c.PostForm("b1")
|
||||
b2h := c.PostForm("b2")
|
||||
scoreA := atoiSafe(c.PostForm("scoreA"))
|
||||
scoreB := atoiSafe(c.PostForm("scoreB"))
|
||||
|
||||
// Resolve players (default A1 to current if empty)
|
||||
if strings.TrimSpace(a1h) == "" {
|
||||
a1h = current.Username
|
||||
}
|
||||
a1, _ := findOrCreateUserByHandle(a1h)
|
||||
var a2, b1, b2 *User
|
||||
if a2h != "" {
|
||||
a2, _ = findOrCreateUserByHandle(a2h)
|
||||
}
|
||||
if b1h != "" {
|
||||
b1, _ = findOrCreateUserByHandle(b1h)
|
||||
}
|
||||
if b2h != "" {
|
||||
b2, _ = findOrCreateUserByHandle(b2h)
|
||||
}
|
||||
|
||||
var tableID *uint
|
||||
if slug := c.Param("tslug"); slug != "" {
|
||||
var t Table
|
||||
if err := db.Where("slug = ?", slug).First(&t).Error; err == nil {
|
||||
tableID = &t.ID
|
||||
}
|
||||
}
|
||||
|
||||
g := Game{ScoreA: scoreA, ScoreB: scoreB, TableID: tableID}
|
||||
if err := db.Create(&g).Error; err != nil {
|
||||
c.String(500, "game create error")
|
||||
return
|
||||
}
|
||||
|
||||
players := []struct {
|
||||
U *User
|
||||
S string
|
||||
}{{a1, "A"}}
|
||||
if a2 != nil {
|
||||
players = append(players, struct {
|
||||
U *User
|
||||
S string
|
||||
}{a2, "A"})
|
||||
}
|
||||
if b1 != nil {
|
||||
players = append(players, struct {
|
||||
U *User
|
||||
S string
|
||||
}{b1, "B"})
|
||||
}
|
||||
if b2 != nil {
|
||||
players = append(players, struct {
|
||||
U *User
|
||||
S string
|
||||
}{b2, "B"})
|
||||
}
|
||||
for _, p := range players {
|
||||
if p.U != nil {
|
||||
db.Create(&GamePlayer{GameID: g.ID, UserID: p.U.ID, Side: p.S})
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(302, "/history")
|
||||
}
|
||||
func getHistory(c *gin.Context) {
|
||||
// Load recent games with players
|
||||
var games []Game
|
||||
db.Order("created_at desc").Limit(100).Preload("Table").Find(&games)
|
||||
|
||||
type GRow struct {
|
||||
Game
|
||||
PlayersA []User
|
||||
PlayersB []User
|
||||
}
|
||||
var rows []GRow
|
||||
for _, g := range games {
|
||||
var gps []GamePlayer
|
||||
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
||||
var a, b []User
|
||||
for _, gp := range gps {
|
||||
if gp.Side == "A" {
|
||||
a = append(a, gp.User)
|
||||
} else {
|
||||
b = append(b, gp.User)
|
||||
}
|
||||
}
|
||||
rows = append(rows, GRow{Game: g, PlayersA: a, PlayersB: b})
|
||||
}
|
||||
render(c, "history", ctxData{"Games": rows})
|
||||
}
|
||||
|
||||
func getLeaderboard(c *gin.Context) {
|
||||
// Simple metric: wins = games where player's side has higher score
|
||||
type Row struct {
|
||||
Username, Slug string
|
||||
Wins, Games int
|
||||
}
|
||||
|
||||
// Load last 1000 games for performance (simple MVP)
|
||||
var games []Game
|
||||
db.Order("created_at desc").Limit(1000).Find(&games)
|
||||
|
||||
// Build map of gameID->winnerSide
|
||||
winner := map[uint]string{}
|
||||
for _, g := range games {
|
||||
side := ""
|
||||
if g.ScoreA > g.ScoreB {
|
||||
side = "A"
|
||||
} else if g.ScoreB > g.ScoreA {
|
||||
side = "B"
|
||||
}
|
||||
winner[g.ID] = side
|
||||
}
|
||||
|
||||
// Count per user
|
||||
type Cnt struct{ Wins, Games int }
|
||||
counts := map[uint]*Cnt{}
|
||||
users := map[uint]User{}
|
||||
|
||||
for _, g := range games {
|
||||
var gps []GamePlayer
|
||||
db.Preload("User").Where("game_id = ?", g.ID).Find(&gps)
|
||||
for _, gp := range gps {
|
||||
if counts[gp.UserID] == nil {
|
||||
counts[gp.UserID] = &Cnt{}
|
||||
users[gp.UserID] = gp.User
|
||||
}
|
||||
counts[gp.UserID].Games++
|
||||
if winner[g.ID] != "" && winner[g.ID] == gp.Side {
|
||||
counts[gp.UserID].Wins++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := []Row{}
|
||||
for uid, cnt := range counts {
|
||||
u := users[uid]
|
||||
rows = append(rows, Row{Username: u.Username, Slug: u.Slug, Wins: cnt.Wins, Games: cnt.Games})
|
||||
}
|
||||
|
||||
// Sort by wins desc, then games desc, then username
|
||||
// Simple insertion sort to avoid extra imports
|
||||
for i := 1; i < len(rows); i++ {
|
||||
j := i
|
||||
for j > 0 && (rows[j-1].Wins < rows[j].Wins || (rows[j-1].Wins == rows[j].Wins && (rows[j-1].Games < rows[j].Games || (rows[j-1].Games == rows[j].Games && rows[j-1].Username > rows[j].Username)))) {
|
||||
rows[j-1], rows[j] = rows[j], rows[j-1]
|
||||
j--
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) > 100 {
|
||||
rows = rows[:100]
|
||||
}
|
||||
|
||||
render(c, "leaderboard", ctxData{"Rows": rows})
|
||||
}
|
||||
|
||||
func getUserView(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
u, err := userBySlug(slug)
|
||||
if err != nil {
|
||||
c.String(404, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Compute stats
|
||||
type Stats struct{ Games, Wins, Losses int }
|
||||
st := Stats{}
|
||||
var gps []GamePlayer
|
||||
db.Where("user_id = ?", u.ID).Find(&gps)
|
||||
gameMap := map[uint]string{}
|
||||
for _, gp := range gps {
|
||||
gameMap[gp.GameID] = gp.Side
|
||||
}
|
||||
|
||||
if len(gameMap) > 0 {
|
||||
var games []Game
|
||||
ids := make([]uint, 0, len(gameMap))
|
||||
for gid := range gameMap {
|
||||
ids = append(ids, gid)
|
||||
}
|
||||
db.Find(&games, ids)
|
||||
for _, g := range games {
|
||||
st.Games++
|
||||
if g.ScoreA == g.ScoreB {
|
||||
continue
|
||||
}
|
||||
if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" {
|
||||
st.Wins++
|
||||
} else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" {
|
||||
st.Wins++
|
||||
} else {
|
||||
st.Losses++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
own := false
|
||||
if cu := getSessionUser(c); cu != nil && cu.ID == u.ID {
|
||||
own = true
|
||||
}
|
||||
|
||||
render(c, "user", ctxData{"Viewed": u, "Stats": st, "Own": own})
|
||||
}
|
||||
|
||||
func getMe(c *gin.Context) {
|
||||
cu := getSessionUser(c)
|
||||
if cu == nil {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
render(c, "user", ctxData{"Viewed": cu, "Stats": struct{ Games, Wins, Losses int }{0, 0, 0}, "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 == "" {
|
||||
c.Redirect(302, "/me")
|
||||
return
|
||||
}
|
||||
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, "/u/"+cu.Slug)
|
||||
}
|
||||
57
src/models.go
Normal file
57
src/models.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
Email string `gorm:"uniqueIndex;size:320"`
|
||||
Username string `gorm:"uniqueIndex;size:64"`
|
||||
Slug string `gorm:"uniqueIndex;size:80"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
// No expiry by default; can add later
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
UserID uint `gorm:"index"`
|
||||
User User
|
||||
}
|
||||
|
||||
type LoginToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
Email string `gorm:"index;size:320"`
|
||||
ExpiresAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Name string
|
||||
Slug string `gorm:"uniqueIndex;size:80"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
TableID *uint `gorm:"index"`
|
||||
Table *Table
|
||||
ScoreA int
|
||||
ScoreB int
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
GameID uint `gorm:"index"`
|
||||
UserID uint `gorm:"index"`
|
||||
Side string `gorm:"size:1;index"` // "A" or "B"
|
||||
User User
|
||||
}
|
||||
132
src/utils.go
Normal file
132
src/utils.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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() }
|
||||
|
||||
func defaultUsername() string {
|
||||
// io-game style: adjective-animal-xxxx
|
||||
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"}
|
||||
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) {
|
||||
var u User
|
||||
tx := db.Where("email = ?", strings.ToLower(email)).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, format 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 render(c *gin.Context, name string, data ctxData) {
|
||||
u := getSessionUser(c)
|
||||
if data == nil {
|
||||
data = ctxData{}
|
||||
}
|
||||
log.Println("tpl error:", name)
|
||||
data["CurrentUser"] = u
|
||||
if err := tpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
||||
log.Println("tpl error:", err)
|
||||
c.Status(500)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user