Refactor project structure and update configurations. Now first working version
- Updated `.air.conf` for Nix compatibility and simplified build commands. - Enhanced `.gitignore` to include `tmp` directory. - Improved `README.md` with clearer instructions and added language details. - Refined CSS styles for better UI consistency and added alert styles. - Upgraded `flake.nix` to use Go 1.24 and improved shell environment setup. - Modified authentication logic in `auth.go` for better user handling. - Updated `main.go` to dynamically set the listening port and improved logging. - Added new `routes.go` file for handling game entry and history. - Enhanced user models and added statistics tracking in `models.go`. - Improved template rendering and added user feedback messages in HTML templates. - Removed obsolete build error logs and binaries.
This commit is contained in:
17
.air.conf
17
.air.conf
@@ -1,17 +1,14 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/main ./src/main.go"
|
||||
bin = "tmp/main"
|
||||
full_bin = "tmp/main"
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
|
||||
exclude_dir = ["tmp", "vendor"]
|
||||
cmd = "nix develop -c go build -o ./tmp/main ./src/..." # for nix users
|
||||
include_ext = ["go", "html", "css"]
|
||||
exclude_dir = ["tmp"]
|
||||
|
||||
[log]
|
||||
level = "warn"
|
||||
time = true
|
||||
|
||||
[colors]
|
||||
main = "yellow"
|
||||
watcher = "cyan"
|
||||
build = "green"
|
||||
[env]
|
||||
GIN_MODE = "debug" # change to "release" for production
|
||||
APP_PORT = 18765
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
qrank.db
|
||||
qrank.db
|
||||
tmp
|
||||
@@ -1,9 +1,12 @@
|
||||
# QRank
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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`. The listening address is `https://localhost:18765`.
|
||||
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`.
|
||||
|
||||
For a development environment install [air](https://github.com/air-verse/air) and run the development server with `air` from the root directory.
|
||||
|
||||
@@ -42,12 +42,42 @@ body {
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
width: 75%;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ddd
|
||||
}
|
||||
|
||||
a, a:link, a:visited, a:hover, a:active {
|
||||
color: darkblue; /* always dark blue */
|
||||
text-decoration: none; /* remove underline */
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #ffe5e5;
|
||||
border: 1px solid #e74c3c;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #e6ffed;
|
||||
border: 1px solid #2ecc71;
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #eaf4ff;
|
||||
border: 1px solid #3498db;
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #444;
|
||||
|
||||
16
flake.nix
16
flake.nix
@@ -12,10 +12,9 @@
|
||||
devShells = forAllSystems (pkgs:
|
||||
let
|
||||
# Pick the Go you want. pkgs.go is fine; change to pkgs.go_1_23 if you prefer a fixed version.
|
||||
go = pkgs.go;
|
||||
go = pkgs.go_1_24;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
# Tools required for cgo and sqlite
|
||||
buildInputs = [
|
||||
go
|
||||
pkgs.gcc
|
||||
@@ -23,25 +22,18 @@
|
||||
pkgs.sqlite
|
||||
];
|
||||
|
||||
# Optional (handy) tools
|
||||
nativeBuildInputs = [
|
||||
pkgs.git
|
||||
];
|
||||
|
||||
# Enable CGO so mattn/go-sqlite3 works
|
||||
CGO_ENABLED = 1;
|
||||
|
||||
# If you plan static linking, uncomment:
|
||||
# hardeningDisable = [ "fortify" ];
|
||||
|
||||
shellHook = ''
|
||||
echo "🔧 Nix dev shell ready (CGO_ENABLED=1)."
|
||||
echo "▶ Run: go run ."
|
||||
echo 'Inside nix shell.'
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
# Convenience runner: `nix run`
|
||||
# Used for nix run
|
||||
apps = forAllSystems (pkgs: {
|
||||
default = {
|
||||
type = "app";
|
||||
@@ -53,7 +45,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
# Optional: formatter for this repo
|
||||
# Nix formatter
|
||||
formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt);
|
||||
};
|
||||
}
|
||||
|
||||
37
src/auth.go
37
src/auth.go
@@ -33,8 +33,8 @@ func requireAuth() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, token string) {
|
||||
// Very long-lived cookie (~10 years)
|
||||
maxAge := 10 * 365 * 24 * 60 * 60
|
||||
// Very long-lived cookie for one year
|
||||
maxAge := 365 * 24 * 60 * 60
|
||||
httpOnly := true
|
||||
secure := strings.HasPrefix(baseURL, "https://")
|
||||
sameSite := http.SameSiteLaxMode
|
||||
@@ -43,36 +43,23 @@ func setSessionCookie(c *gin.Context, token string) {
|
||||
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) {
|
||||
func findUserByHandle(handle string) (*User, error) {
|
||||
h := strings.TrimSpace(handle)
|
||||
if h == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
if strings.Contains(h, "@") { // email
|
||||
|
||||
// Return user by email or username
|
||||
if strings.Contains(h, "@") {
|
||||
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
|
||||
} else {
|
||||
// Find and return the user
|
||||
if u, err := userByName(h); err == nil {
|
||||
return u, nil
|
||||
}
|
||||
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
|
||||
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
51
src/main.go
51
src/main.go
@@ -4,8 +4,9 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -16,46 +17,44 @@ var (
|
||||
db *gorm.DB
|
||||
baseURL string
|
||||
cookieDomain string
|
||||
|
||||
// Global logger for this package
|
||||
lg = log.Default()
|
||||
)
|
||||
|
||||
const (
|
||||
PORT = "18765"
|
||||
// Default port if APP_PORT is not set
|
||||
defaultPort = 18765
|
||||
)
|
||||
|
||||
var logger = log.Default()
|
||||
|
||||
// ===================== Main =====================
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
// Get the listening port
|
||||
port := os.Getenv("APP_PORT")
|
||||
port = strconv.Itoa(defaultPort)
|
||||
|
||||
// Set the base URL
|
||||
baseURL = os.Getenv("APP_BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:" + PORT
|
||||
baseURL = "http://localhost:" + port
|
||||
}
|
||||
cookieDomain = os.Getenv("APP_COOKIE_DOMAIN")
|
||||
|
||||
// DB connect
|
||||
// Open connection to SQLite
|
||||
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")
|
||||
db, err = gorm.Open(sqlite.Open("qrank.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
lg.Fatal("sqlite connect:", err)
|
||||
}
|
||||
lg.Println("using SQLite qrank.db")
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &LoginToken{}, &Table{}, &Game{}, &GamePlayer{}); err != nil {
|
||||
log.Fatal("migrate:", err)
|
||||
lg.Fatal("migrate:", err)
|
||||
}
|
||||
|
||||
// Create engine
|
||||
r := gin.Default()
|
||||
|
||||
// Serve static files from the current directory
|
||||
@@ -82,12 +81,10 @@ func main() {
|
||||
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)
|
||||
// Start application with port
|
||||
bind := ":" + port
|
||||
lg.Println("listening on", bind, "base:", baseURL)
|
||||
if err := r.Run(bind); err != nil {
|
||||
log.Fatal(err)
|
||||
lg.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// One knows that the user is active when there exists a session for the user
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
@@ -14,13 +15,13 @@ type User struct {
|
||||
Slug string `gorm:"uniqueIndex;size:80"`
|
||||
}
|
||||
|
||||
// Currently no expiry
|
||||
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
|
||||
Token string `gorm:"uniqueIndex;size:128"`
|
||||
UserID uint `gorm:"index"`
|
||||
User User
|
||||
}
|
||||
|
||||
type LoginToken struct {
|
||||
@@ -46,6 +47,7 @@ type Game struct {
|
||||
Table *Table
|
||||
ScoreA int
|
||||
ScoreB int
|
||||
WinnerIsA bool
|
||||
}
|
||||
|
||||
type GamePlayer struct {
|
||||
@@ -55,3 +57,10 @@ type GamePlayer struct {
|
||||
Side string `gorm:"size:1;index"` // "A" or "B"
|
||||
User User
|
||||
}
|
||||
|
||||
// Also house the common models that are not in the database here
|
||||
type stats struct {
|
||||
Games,
|
||||
Wins,
|
||||
Losses int
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,125 +12,86 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func getIndex(c *gin.Context) {
|
||||
if getSessionUser(c) != nil {
|
||||
c.Redirect(302, "/enter")
|
||||
return
|
||||
}
|
||||
getLogin(c)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
tm.Render(c, "enter", gin.H{"PostAction": post, "Table": table})
|
||||
// Simple render for now
|
||||
tm.Render(c, "enter", gin.H{})
|
||||
}
|
||||
|
||||
func postEnter(c *gin.Context) {
|
||||
current := getSessionUser(c)
|
||||
if current == nil {
|
||||
c.Redirect(302, "/login")
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
return
|
||||
}
|
||||
|
||||
a1h := c.PostForm("a1")
|
||||
a2h := c.PostForm("a2")
|
||||
b1h := c.PostForm("b1")
|
||||
b2h := c.PostForm("b2")
|
||||
// Parse form
|
||||
a1h := strings.TrimSpace(c.PostForm("a1"))
|
||||
a2h := strings.TrimSpace(c.PostForm("a2"))
|
||||
b1h := strings.TrimSpace(c.PostForm("b1"))
|
||||
b2h := strings.TrimSpace(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)
|
||||
// Require a winner
|
||||
if scoreA == scoreB {
|
||||
tm.Render(c, "enter", gin.H{"Error": "Score A score must be different from score B",
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve players
|
||||
resolveUser := func(handle string) (*User, error) {
|
||||
if handle == "" {
|
||||
return nil, nil
|
||||
}
|
||||
u, err := findUserByHandle(handle)
|
||||
if err != nil {
|
||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q not found", handle),
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
var a1, a2, b1, b2 *User
|
||||
|
||||
// Always ensure A1 exists, fallback to current if empty
|
||||
var err error
|
||||
if a1, err = resolveUser(a1h); err != nil {
|
||||
return
|
||||
}
|
||||
if a2, err = resolveUser(a2h); err != nil {
|
||||
return
|
||||
}
|
||||
if b1, err = resolveUser(b1h); err != nil {
|
||||
return
|
||||
}
|
||||
if b2, err = resolveUser(b2h); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check for at least one player on each side
|
||||
if b1 == nil && b2 == nil || a1 == nil && a2 == nil {
|
||||
tm.Render(c, "enter", gin.H{"Error": "At least one player required on each side",
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate users
|
||||
seen := map[uint]bool{}
|
||||
users := []*User{a1, a2, b1, b2}
|
||||
for i, u := range users {
|
||||
if u != nil {
|
||||
if seen[u.ID] {
|
||||
tm.Render(c, "enter", gin.H{"Error": fmt.Sprintf("User %q specified multiple times", users[i].Username),
|
||||
"a1": a1h, "a2": a2h, "b1": b1h, "b2": b2h, "scoreA": scoreA, "scoreB": scoreB})
|
||||
return
|
||||
}
|
||||
seen[u.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Look up table if provided
|
||||
var tableID *uint
|
||||
if slug := c.Param("tslug"); slug != "" {
|
||||
var t Table
|
||||
@@ -137,42 +100,49 @@ func postEnter(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
g := Game{ScoreA: scoreA, ScoreB: scoreB, TableID: tableID}
|
||||
// Create game
|
||||
g := Game{
|
||||
ScoreA: scoreA,
|
||||
ScoreB: scoreB,
|
||||
TableID: tableID,
|
||||
WinnerIsA: scoreA > scoreB,
|
||||
}
|
||||
if err := db.Create(&g).Error; err != nil {
|
||||
c.String(500, "game create error")
|
||||
c.String(http.StatusInternalServerError, "game create error")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect players by side
|
||||
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"})
|
||||
u *User
|
||||
side string
|
||||
}{
|
||||
{a1, "A"},
|
||||
{a2, "A"},
|
||||
{b1, "B"},
|
||||
{b2, "B"},
|
||||
}
|
||||
|
||||
for _, p := range players {
|
||||
if p.U != nil {
|
||||
db.Create(&GamePlayer{GameID: g.ID, UserID: p.U.ID, Side: p.S})
|
||||
if p.u != nil {
|
||||
if err := db.Create(&GamePlayer{GameID: g.ID, UserID: p.u.ID, Side: p.side}).Error; err != nil {
|
||||
c.String(http.StatusInternalServerError, "failed to assign player")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Redirect(302, "/history")
|
||||
c.Redirect(http.StatusFound, "/history")
|
||||
}
|
||||
|
||||
func getIndex(c *gin.Context) {
|
||||
if u := getSessionUser(c); u != nil {
|
||||
c.Redirect(302, "/enter")
|
||||
return
|
||||
}
|
||||
getLogin(c)
|
||||
}
|
||||
|
||||
func getHistory(c *gin.Context) {
|
||||
// Load recent games with players
|
||||
var games []Game
|
||||
@@ -274,53 +244,21 @@ func getUserView(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": st, "Own": own})
|
||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": own})
|
||||
}
|
||||
|
||||
func getMe(c *gin.Context) {
|
||||
cu := getSessionUser(c)
|
||||
if cu == nil {
|
||||
u := getSessionUser(c)
|
||||
if u == nil {
|
||||
c.Redirect(302, "/login")
|
||||
return
|
||||
}
|
||||
tm.Render(c, "user", gin.H{"Viewed": cu, "Stats": struct{ Games, Wins, Losses int }{0, 0, 0}, "Own": true})
|
||||
tm.Render(c, "user", gin.H{"Viewed": u, "Stats": getStatsFromUser(u), "Own": true})
|
||||
}
|
||||
|
||||
func postMe(c *gin.Context) {
|
||||
@@ -330,10 +268,12 @@ func postMe(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
newU := strings.TrimSpace(c.PostForm("username"))
|
||||
if newU == "" {
|
||||
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 {
|
||||
@@ -342,5 +282,71 @@ func postMe(c *gin.Context) {
|
||||
if err := db.Save(cu).Error; err != nil {
|
||||
log.Println("save user:", err)
|
||||
}
|
||||
c.Redirect(302, "/u/"+cu.Slug)
|
||||
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))
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -30,14 +30,20 @@ func NewTemplateManager(dir string, base string, ext string) *TemplateManager {
|
||||
tm := &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcs: template.FuncMap{
|
||||
// Attach functions to the templates here
|
||||
"fmtTime": func(t time.Time) string {
|
||||
return t.Local().Format("2006-01-02 15:04") // Go’s reference time
|
||||
},
|
||||
"cmpTwo": func(a, b int) bool {
|
||||
return a == b
|
||||
},
|
||||
},
|
||||
base: base,
|
||||
dir: dir,
|
||||
ext: ext,
|
||||
}
|
||||
|
||||
// Populate template manager
|
||||
tm.LoadTemplates()
|
||||
|
||||
return tm
|
||||
@@ -58,7 +64,7 @@ func (tm *TemplateManager) LoadTemplates() {
|
||||
}
|
||||
|
||||
name := stripAfterDot(filepath.Base(file))
|
||||
logger.Println(name)
|
||||
lg.Println(name)
|
||||
|
||||
// Parse base + view template together
|
||||
tpl, err := template.New(name).
|
||||
|
||||
56
src/utils.go
56
src/utils.go
@@ -16,12 +16,15 @@ func mustRandToken(n int) string {
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now().UTC() }
|
||||
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 {
|
||||
// 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"}
|
||||
// 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))
|
||||
@@ -72,8 +75,18 @@ func ensureUniqueUsernameAndSlug(u *User) error {
|
||||
}
|
||||
|
||||
func userByEmail(email string) (*User, error) {
|
||||
// Email must be lowercased and trimmed
|
||||
var u User
|
||||
tx := db.Where("email = ?", strings.ToLower(email)).First(&u)
|
||||
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
|
||||
}
|
||||
@@ -121,3 +134,36 @@ func stripAfterDot(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getStatsFromUser(u *User) stats {
|
||||
var gps []GamePlayer
|
||||
var games, wins, losses int
|
||||
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 gamesL []Game
|
||||
ids := make([]uint, 0, len(gameMap))
|
||||
for gid := range gameMap {
|
||||
ids = append(ids, gid)
|
||||
}
|
||||
db.Find(&gamesL, ids)
|
||||
for _, g := range gamesL {
|
||||
games++
|
||||
if g.ScoreA == g.ScoreB {
|
||||
continue
|
||||
}
|
||||
if g.ScoreA > g.ScoreB && gameMap[g.ID] == "A" {
|
||||
wins++
|
||||
} else if g.ScoreB > g.ScoreA && gameMap[g.ID] == "B" {
|
||||
wins++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats{Games: games, Wins: wins, Losses: losses}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}QRank{{end}}</title>
|
||||
<title>QRank</title>
|
||||
|
||||
<!-- Also apple support -->
|
||||
<link rel="icon" type="image/png" href="/assets/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="/assets/favicon.ico">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico">
|
||||
|
||||
<!-- Only one stylesheet for now -->
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
|
||||
@@ -18,12 +20,14 @@
|
||||
<div class="header">
|
||||
<div class="container">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<div><strong><a href="/" style="text-decoration:none;color:#111">QRank</a></strong></div>
|
||||
<div><strong><a href="/" style="text-decoration:none;color:#111">{{block "title" .}}QRank{{end}}</a></strong></div>
|
||||
|
||||
<!-- Navigation menu -->
|
||||
<div class="nav">
|
||||
{{if .CurrentUser}}
|
||||
<a href="/enter">Enter scores</a>
|
||||
<a href="/history">History</a>
|
||||
<a href="/leaderboard">Leaderboard</a>
|
||||
{{if .CurrentUser}}
|
||||
<a href="/me">@{{.CurrentUser.Username}}</a>
|
||||
{{else}}
|
||||
<a href="/login">Log in</a>
|
||||
@@ -33,6 +37,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<!-- Information messages for the user -->
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
{{if .Success}}
|
||||
<div class="alert alert-success">{{.Success}}</div>
|
||||
{{end}}
|
||||
{{if .Message}}
|
||||
<div class="alert alert-info">{{.Message}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- This is shorthand for a define along with a template -->
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
{{define "title"}}Enter scores - QRank{{end}}
|
||||
{{define "title"}}QRank{{end}}
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Enter a game</h2>
|
||||
{{if .Table}}<p class="small">Table: <span class="badge">{{.Table.Name}}</span></p>{{end}}
|
||||
<form method="POST" action="{{.PostAction}}">
|
||||
|
||||
{{if .Table}}
|
||||
<p class="small">Table: <span class="badge">{{.Table.Name}}</span></p>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/enter">
|
||||
<div class="row">
|
||||
<div style="flex:1;min-width:280px">
|
||||
<div style="flex:1;min-width:180px">
|
||||
<h3>Team A</h3>
|
||||
<label class="label">Player A1</label>
|
||||
<input class="input" name="a1" value="{{.CurrentUser.Username}}">
|
||||
<input class="input" name="a1" {{if .a1}}value="{{.a1}}"{{else}}value="{{.CurrentUser.Username}}"{{end}} placeholder="username or email">
|
||||
<div style="height:6px"></div>
|
||||
<label class="label">Player A2 (optional)</label>
|
||||
<input class="input" name="a2" placeholder="username or email">
|
||||
<input class="input" name="a2" {{if .a2}}value="{{.a2}}"{{end}} placeholder="username or email">
|
||||
<div style="height:12px"></div>
|
||||
<label class="label">Score A</label>
|
||||
<input class="input" type="number" name="scoreA" value="10" min="0" required>
|
||||
<input class="input" type="number" name="scoreA" {{if .scoreA}}value="{{.scoreA}}"{{else}}value="0"{{end}} min="0" max="10" required>
|
||||
</div>
|
||||
<div style="flex:1;min-width:280px">
|
||||
|
||||
<div style="flex:1;min-width:180px">
|
||||
<h3>Team B</h3>
|
||||
<label class="label">Player B1</label>
|
||||
<input class="input" name="b1" placeholder="username or email">
|
||||
<input class="input" name="b1" {{if .b1}}value="{{.b1}}"{{end}} placeholder="username or email">
|
||||
<div style="height:6px"></div>
|
||||
<label class="label">Player B2 (optional)</label>
|
||||
<input class="input" name="b2" placeholder="username or email">
|
||||
<input class="input" name="b2" {{if .b2}}value="{{.b2}}"{{end}} placeholder="username or email">
|
||||
<div style="height:12px"></div>
|
||||
<label class="label">Score B</label>
|
||||
<input class="input" type="number" name="scoreB" value="8" min="0" required>
|
||||
<input class="input" type="number" name="scoreB" {{if .scoreB}}value="{{.scoreB}}"{{else}}value="0"{{end}} min="0" max="10" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height:12px"></div>
|
||||
<button class="btn btn-primary" type="submit">Submit result</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="small">{{fmtTime .CreatedAt}}</div>
|
||||
<div>
|
||||
{{if .Table}}<span class="badge">{{.Table.Name}}</span> {{end}}
|
||||
Team A {{.ScoreA}} \u2013 {{.ScoreB}} Team B
|
||||
{{if .WinnerIsA}}{{range .PlayersA}}{{.Username}} {{end}}{{.ScoreA}}/{{.ScoreB}}{{else}}{{range .PlayersB}}{{.Username}} {{end}}{{.ScoreB}}/{{.ScoreA}}{{end}}
|
||||
</div>
|
||||
<div class="small">
|
||||
{{range .PlayersA}}@{{.Username}} {{end}}vs {{range .PlayersB}}@{{.Username}} {{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2>Leaderboard (by wins)</h2>
|
||||
<ol>
|
||||
{{range .Rows}}
|
||||
<li>@<a href="/u/{{.Slug}}">{{.Username}}</a> \u2014 {{.Wins}} wins ({{.Games}} games)</li>
|
||||
<li>@<a href="/u/{{.Slug}}">{{.Username}}</a> - {{.Wins}} wins ({{.Games}} games)</li>
|
||||
{{else}}
|
||||
<li class="small">No players yet.</li>
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Check the server logs</h2>
|
||||
<p class="small">We just printed a magic link for <strong>{{.Email}}</strong>. Open it here to sign in.
|
||||
<h2>Check your email inbox</h2>
|
||||
<p class="small">We just printed a magic link for <strong>{{.Email}}</strong>. Open it to sign in.
|
||||
Token expires in 30 days.</p>
|
||||
<a class="btn" href="/login">Back</a>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="card">
|
||||
<h2>@{{.Viewed.Username}}</h2>
|
||||
<p class="small">Joined {{fmtTime .Viewed.CreatedAt}}</p>
|
||||
<p>Games played: {{.Stats.Games}} Wins: {{.Stats.Wins}} Losses: {{.Stats.Losses}}</p>
|
||||
<p>Game score {{.Stats.Games}}/{{.Stats.Wins}}/{{.Stats.Losses}}</p>
|
||||
{{if .Own}}
|
||||
<hr>
|
||||
<h3>Update your profile</h3>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
Reference in New Issue
Block a user