Replaced one-pager with multiple pages and fixed security bugs

This commit is contained in:
Jan Meinl
2026-05-16 21:10:55 +02:00
parent 802906f9d4
commit 68034dea7d
25 changed files with 2311 additions and 1217 deletions
+1
View File
@@ -2,3 +2,4 @@
/penaltytracker.db /penaltytracker.db
/penaltytracker.db-shm /penaltytracker.db-shm
/penaltytracker.db-wal /penaltytracker.db-wal
/config.json
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+125 -27
View File
@@ -6,8 +6,10 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user"
const sessionCookie = "pt_session" const sessionCookie = "pt_session"
const sessionDuration = 24 * time.Hour * 14 const sessionDuration = 24 * time.Hour * 14
const maxUsernameLen = 64
const maxDisplayNameLen = 128
const maxPasswordLen = 256
const maxFieldLen = 2000
func registerAuthRoutes(mux *http.ServeMux) { func registerAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/login", handleLogin) mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
mux.HandleFunc("POST /api/logout", handleLogout) mux.HandleFunc("POST /api/logout", handleLogout)
mux.HandleFunc("GET /api/me", requireAuth(handleMe)) mux.HandleFunc("GET /api/me", requireAuth(handleMe))
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe)) mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
@@ -40,7 +47,7 @@ func ensureDefaultAdmin() error {
return err return err
} }
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)", "INSERT INTO users(username,password_hash,display_name,language,is_system_admin,must_change_password) VALUES(?,?,?,?,1,1)",
"admin", string(hash), "System Admin", "en", "admin", string(hash), "System Admin", "en",
) )
return err return err
@@ -54,6 +61,79 @@ func newToken() (string, error) {
return hex.EncodeToString(b), nil return hex.EncodeToString(b), nil
} }
// ---- login rate limiter ---------------------------------------------------
type loginAttempts struct {
times []time.Time
}
var (
loginMu sync.Mutex
loginAttempts_ = map[string]*loginAttempts{}
)
const loginMaxAttempts = 8
const loginWindow = 5 * time.Minute
func clientIP(r *http.Request) string {
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
parts := strings.Split(xf, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
a, ok := loginAttempts_[ip]
if !ok {
a = &loginAttempts{}
loginAttempts_[ip] = a
}
cutoff := time.Now().Add(-loginWindow)
kept := a.times[:0]
for _, t := range a.times {
if t.After(cutoff) {
kept = append(kept, t)
}
}
a.times = kept
return len(a.times) < loginMaxAttempts
}
func loginRecord(ip string) {
loginMu.Lock()
defer loginMu.Unlock()
a, ok := loginAttempts_[ip]
if !ok {
a = &loginAttempts{}
loginAttempts_[ip] = a
}
a.times = append(a.times, time.Now())
}
func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !loginAllowed(ip) {
writeError(w, http.StatusTooManyRequests, "too_many_attempts")
return
}
h.ServeHTTP(w, r)
}
}
// ---- handlers --------------------------------------------------------------
func normalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func handleLogin(w http.ResponseWriter, r *http.Request) { func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
@@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body") writeError(w, http.StatusBadRequest, "invalid_body")
return return
} }
req.Username = strings.TrimSpace(req.Username) req.Username = normalizeUsername(req.Username)
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_credentials") writeError(w, http.StatusBadRequest, "missing_credentials")
return return
} }
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
var id int64 var id int64
var hash string var hash string
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash) err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
if err != nil { if err != nil {
loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials") writeError(w, http.StatusUnauthorized, "invalid_credentials")
return return
} }
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials") writeError(w, http.StatusUnauthorized, "invalid_credentials")
return return
} }
@@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
Expires: expires, Expires: expires,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
} }
if crossSiteCookies { if crossSiteCookies {
cookie.SameSite = http.SameSiteNoneMode cookie.SameSite = http.SameSiteNoneMode
@@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1, MaxAge: -1,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
} }
if crossSiteCookies { if crossSiteCookies {
clear.SameSite = http.SameSiteNoneMode clear.SameSite = http.SameSiteNoneMode
@@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, u) writeJSON(w, http.StatusOK, u)
} }
// handleUpdateMe lets the logged-in user change ONLY their language and password.
// Username and display name are administrative attributes and can only be set
// through the admin user endpoints.
func handleUpdateMe(w http.ResponseWriter, r *http.Request) { func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r) u := userFromCtx(r)
var req struct { var req struct {
Username *string `json:"username"` Language *string `json:"language"`
Language *string `json:"language"` Password *string `json:"password"`
DisplayName *string `json:"display_name"`
Password *string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body") writeError(w, http.StatusBadRequest, "invalid_body")
return return
} }
if req.Username != nil {
newName := strings.TrimSpace(*req.Username)
if newName == "" {
writeError(w, http.StatusBadRequest, "missing_username")
return
}
if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil {
writeError(w, http.StatusConflict, "username_taken")
return
}
}
if req.Language != nil { if req.Language != nil {
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil { lang := strings.TrimSpace(*req.Language)
writeError(w, http.StatusInternalServerError, "db_error") if len(lang) > 8 {
writeError(w, http.StatusBadRequest, "invalid_language")
return return
} }
} if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil {
if req.DisplayName != nil {
if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error") writeError(w, http.StatusInternalServerError, "db_error")
return return
} }
} }
if req.Password != nil && *req.Password != "" { if req.Password != nil && *req.Password != "" {
if len(*req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "hash_error") writeError(w, http.StatusInternalServerError, "hash_error")
return return
} }
if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil { if _, err := db.Exec("UPDATE users SET password_hash=?,must_change_password=0 WHERE id=?", string(hash), u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error") writeError(w, http.StatusInternalServerError, "db_error")
return return
} }
@@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
func loadUser(id int64) (*User, error) { func loadUser(id int64) (*User, error) {
u := &User{} u := &User{}
var admin int var admin, mustChange int
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id). err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users WHERE id=?", id).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.IsSystemAdmin = admin == 1 u.IsSystemAdmin = admin == 1
u.MustChangePassword = mustChange == 1
return u, nil return u, nil
} }
@@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) {
return loadUser(userID) return loadUser(userID)
} }
// passwordChangeExempt returns true for endpoints that a user must remain able
// to access even while in the "must change password" lock state.
func passwordChangeExempt(r *http.Request) bool {
if r.URL.Path == "/api/me" {
return true
}
if r.URL.Path == "/api/logout" {
return true
}
return false
}
func requireAuth(h http.HandlerFunc) http.HandlerFunc { func requireAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
u, err := authUser(r) u, err := authUser(r)
@@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc {
writeError(w, http.StatusUnauthorized, "unauthorized") writeError(w, http.StatusUnauthorized, "unauthorized")
return return
} }
if u.MustChangePassword && !passwordChangeExempt(r) {
writeError(w, http.StatusForbidden, "password_change_required")
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u) ctx := context.WithValue(r.Context(), userCtxKey, u)
h.ServeHTTP(w, r.WithContext(ctx)) h.ServeHTTP(w, r.WithContext(ctx))
} }
+4
View File
@@ -83,6 +83,10 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "missing_name") writeError(w, http.StatusBadRequest, "missing_name")
return return
} }
if len(req.Name) > 200 {
writeError(w, http.StatusBadRequest, "too_long")
return
}
allow := 0 allow := 0
if req.AllowAnyScorerEdit { if req.AllowAnyScorerEdit {
allow = 1 allow = 1
+8
View File
@@ -88,5 +88,13 @@ func migrate() error {
return err return err
} }
} }
// Idempotent column additions for older databases.
addColumns := []string{
`ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
}
for _, s := range addColumns {
// Ignore "duplicate column" errors so the migration is idempotent.
_, _ = db.Exec(s)
}
return nil return nil
} }
+80 -1
View File
@@ -6,7 +6,9 @@ import (
"errors" "errors"
"flag" "flag"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
@@ -142,9 +144,11 @@ func main() {
registerRuleRoutes(mux) registerRuleRoutes(mux)
registerWSRoutes(mux) registerWSRoutes(mux)
handler := withSecurityHeaders(withLog(withCSRF(withCORS(mux))))
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: withLog(withCORS(mux)), Handler: handler,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
@@ -171,6 +175,22 @@ func withLog(next http.Handler) http.Handler {
}) })
} }
func withSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()")
h.Set("Cross-Origin-Resource-Policy", "same-site")
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func originAllowed(origin string) bool { func originAllowed(origin string) bool {
if origin == "" { if origin == "" {
return false return false
@@ -186,6 +206,65 @@ func originAllowed(origin string) bool {
return false return false
} }
func sameOriginRequest(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// Fall back to Referer
ref := r.Header.Get("Referer")
if ref == "" {
// No origin info: only safe methods allowed. State-changing must have Origin.
return false
}
u, err := url.Parse(ref)
if err != nil {
return false
}
origin = u.Scheme + "://" + u.Host
}
u, err := url.Parse(origin)
if err != nil {
return false
}
originHost, _, _ := net.SplitHostPort(u.Host)
if originHost == "" {
originHost = u.Host
}
reqHost, _, _ := net.SplitHostPort(r.Host)
if reqHost == "" {
reqHost = r.Host
}
return strings.EqualFold(originHost, reqHost)
}
func isStateChanging(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
}
return false
}
func withCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isStateChanging(r.Method) {
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
// Allow if Origin matches a configured CORS origin.
if origin != "" && originAllowed(origin) {
next.ServeHTTP(w, r)
return
}
// Allow same-origin requests (Origin or Referer host matches request Host).
if sameOriginRequest(r) {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusForbidden, "csrf_forbidden")
})
}
func withCORS(next http.Handler) http.Handler { func withCORS(next http.Handler) http.Handler {
if len(corsOrigins) == 0 { if len(corsOrigins) == 0 {
return next return next
+6 -5
View File
@@ -1,11 +1,12 @@
package main package main
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Language string `json:"language"` Language string `json:"language"`
IsSystemAdmin bool `json:"is_system_admin"` IsSystemAdmin bool `json:"is_system_admin"`
MustChangePassword bool `json:"must_change_password"`
} }
type Competition struct { type Competition struct {
+99 -8
View File
@@ -9,12 +9,91 @@ import (
"strings" "strings"
) )
// csvSafe prefixes potentially dangerous CSV cell content with a single quote
// so that spreadsheet apps don't interpret it as a formula.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}
func clip(s string, max int) string {
if len(s) > max {
return s[:max]
}
return s
}
func registerPenaltyRoutes(mux *http.ServeMux) { func registerPenaltyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties)) mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty)) mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty)) mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty)) mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties)) mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties))
mux.HandleFunc("POST /api/competitions/{id}/penalties/apply", requireAuth(handleApplyPenalties))
}
// handleApplyPenalties bulk-marks penalties as applied (transferred=1) for a
// given task. Requires chief_scorer or system_admin role.
func handleApplyPenalties(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
u := userFromCtx(r)
role, ok := canAccessCompetition(u, id)
if !ok || (role != "chief_scorer" && role != "system_admin") {
writeError(w, http.StatusForbidden, "forbidden")
return
}
var req struct {
Task string `json:"task"`
IDs []int64 `json:"ids"`
Applied bool `json:"applied"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
v := 0
if req.Applied {
v = 1
}
tx, err := db.Begin()
if err != nil {
writeError(w, http.StatusInternalServerError, "tx_error")
return
}
defer tx.Rollback()
if len(req.IDs) > 0 {
stmt, err := tx.Prepare("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE id=? AND competition_id=?")
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
defer stmt.Close()
for _, pid := range req.IDs {
stmt.Exec(v, pid, id)
}
} else {
task := clip(req.Task, 64)
if _, err := tx.Exec("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE competition_id=? AND task=?", v, id, task); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
if err := tx.Commit(); err != nil {
writeError(w, http.StatusInternalServerError, "commit_error")
return
}
hub.broadcast(id, "penalties_applied", map[string]any{"task": req.Task, "applied": req.Applied, "ids": req.IDs})
w.WriteHeader(http.StatusNoContent)
} }
func loadPenalty(id int64) (*Penalty, error) { func loadPenalty(id int64) (*Penalty, error) {
@@ -92,6 +171,13 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body") writeError(w, http.StatusBadRequest, "invalid_body")
return return
} }
pen.Flight = clip(pen.Flight, 64)
pen.Date = clip(pen.Date, 32)
pen.PilotNumber = clip(pen.PilotNumber, 32)
pen.RuleNumber = clip(pen.RuleNumber, 64)
pen.Task = clip(pen.Task, 64)
pen.PenaltiesText = clip(pen.PenaltiesText, 256)
pen.Description = clip(pen.Description, maxFieldLen)
transferred := 0 transferred := 0
if pen.Transferred { if pen.Transferred {
transferred = 1 transferred = 1
@@ -171,31 +257,31 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) {
args := []any{} args := []any{}
if req.Flight != nil { if req.Flight != nil {
sets = append(sets, "flight=?") sets = append(sets, "flight=?")
args = append(args, *req.Flight) args = append(args, clip(*req.Flight, 64))
} }
if req.Date != nil { if req.Date != nil {
sets = append(sets, "date=?") sets = append(sets, "date=?")
args = append(args, *req.Date) args = append(args, clip(*req.Date, 32))
} }
if req.PilotNumber != nil { if req.PilotNumber != nil {
sets = append(sets, "pilot_number=?") sets = append(sets, "pilot_number=?")
args = append(args, *req.PilotNumber) args = append(args, clip(*req.PilotNumber, 32))
} }
if req.RuleNumber != nil { if req.RuleNumber != nil {
sets = append(sets, "rule_number=?") sets = append(sets, "rule_number=?")
args = append(args, *req.RuleNumber) args = append(args, clip(*req.RuleNumber, 64))
} }
if req.Task != nil { if req.Task != nil {
sets = append(sets, "task=?") sets = append(sets, "task=?")
args = append(args, *req.Task) args = append(args, clip(*req.Task, 64))
} }
if req.PenaltiesText != nil { if req.PenaltiesText != nil {
sets = append(sets, "penalties_text=?") sets = append(sets, "penalties_text=?")
args = append(args, *req.PenaltiesText) args = append(args, clip(*req.PenaltiesText, 256))
} }
if req.Description != nil { if req.Description != nil {
sets = append(sets, "description=?") sets = append(sets, "description=?")
args = append(args, *req.Description) args = append(args, clip(*req.Description, maxFieldLen))
} }
if req.Transferred != nil { if req.Transferred != nil {
v := 0 v := 0
@@ -287,7 +373,12 @@ func handleExportPenalties(w http.ResponseWriter, r *http.Request) {
if transferred == 1 { if transferred == 1 {
t = "1" t = "1"
} }
cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt}) cw.Write([]string{
strconv.FormatInt(idv, 10),
csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname),
csvSafe(rnum), csvSafe(task), csvSafe(pens), csvSafe(desc),
csvSafe(creator), t, createdAt,
})
} }
cw.Flush() cw.Flush()
} }
+5
View File
@@ -72,6 +72,11 @@ func handleCreatePilot(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "missing_fields") writeError(w, http.StatusBadRequest, "missing_fields")
return return
} }
if len(p.Number) > 32 || len(p.LastName) > 128 || len(p.FirstName) > 128 ||
len(p.Country) > 64 || len(p.BalloonID) > 64 {
writeError(w, http.StatusBadRequest, "too_long")
return
}
res, err := db.Exec( res, err := db.Exec(
"INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)", "INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)",
id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID,
+41 -8
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -17,7 +16,7 @@ func registerUserRoutes(mux *http.ServeMux) {
} }
func handleListUsers(w http.ResponseWriter, r *http.Request) { func handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin FROM users ORDER BY username") rows, err := db.Query("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users ORDER BY username")
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "db_error") writeError(w, http.StatusInternalServerError, "db_error")
return return
@@ -26,9 +25,10 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
out := []User{} out := []User{}
for rows.Next() { for rows.Next() {
var u User var u User
var admin int var admin, mustChange int
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin) rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
u.IsSystemAdmin = admin == 1 u.IsSystemAdmin = admin == 1
u.MustChangePassword = mustChange == 1
out = append(out, u) out = append(out, u)
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
@@ -47,11 +47,16 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body") writeError(w, http.StatusBadRequest, "invalid_body")
return return
} }
req.Username = strings.TrimSpace(req.Username) req.Username = normalizeUsername(req.Username)
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_fields") writeError(w, http.StatusBadRequest, "missing_fields")
return return
} }
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen ||
len(req.DisplayName) > maxDisplayNameLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
if req.IsSystemAdmin && !actor.IsSystemAdmin { if req.IsSystemAdmin && !actor.IsSystemAdmin {
writeError(w, http.StatusForbidden, "forbidden") writeError(w, http.StatusForbidden, "forbidden")
return return
@@ -114,18 +119,39 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
DisplayName *string `json:"display_name"` Username *string `json:"username"`
Password *string `json:"password"` DisplayName *string `json:"display_name"`
IsSystemAdmin *bool `json:"is_system_admin"` Password *string `json:"password"`
IsSystemAdmin *bool `json:"is_system_admin"`
MustChangePassword *bool `json:"must_change_password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body") writeError(w, http.StatusBadRequest, "invalid_body")
return return
} }
if req.Username != nil {
newName := normalizeUsername(*req.Username)
if newName == "" || len(newName) > maxUsernameLen {
writeError(w, http.StatusBadRequest, "invalid_username")
return
}
if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, id); err != nil {
writeError(w, http.StatusConflict, "username_taken")
return
}
}
if req.DisplayName != nil { if req.DisplayName != nil {
if len(*req.DisplayName) > maxDisplayNameLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id) db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
} }
if req.Password != nil && *req.Password != "" { if req.Password != nil && *req.Password != "" {
if len(*req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id) db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id)
} }
@@ -136,6 +162,13 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
} }
db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id) db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id)
} }
if req.MustChangePassword != nil {
v := 0
if *req.MustChangePassword {
v = 1
}
db.Exec("UPDATE users SET must_change_password=? WHERE id=?", v, id)
}
u, _ := loadUser(id) u, _ := loadUser(id)
writeJSON(w, http.StatusOK, u) writeJSON(w, http.StatusOK, u)
} }
+8 -2
View File
@@ -22,13 +22,14 @@ async function api(method, path, body) {
} }
const API = { const API = {
login: (u, p) => api("POST", "/api/login", { username: u, password: p }), login: (u, p) => api("POST", "/api/login", { username: String(u || "").toLowerCase().trim(), password: p }),
logout: () => api("POST", "/api/logout"), logout: () => api("POST", "/api/logout"),
me: () => api("GET", "/api/me"), me: () => api("GET", "/api/me"),
updateMe: (b) => api("PATCH", "/api/me", b), updateMe: (b) => api("PATCH", "/api/me", b),
listUsers: () => api("GET", "/api/users"), listUsers: () => api("GET", "/api/users"),
createUser: (b) => api("POST", "/api/users", b), createUser: (b) => api("POST", "/api/users", b),
updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b),
deleteUser: (id) => api("DELETE", `/api/users/${id}`), deleteUser: (id) => api("DELETE", `/api/users/${id}`),
listCompetitions: () => api("GET", "/api/competitions"), listCompetitions: () => api("GET", "/api/competitions"),
@@ -60,7 +61,12 @@ const API = {
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b), createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b), updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`), deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`),
exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`), applyPenalties: (id, b) => api("POST", `/api/competitions/${id}/penalties/apply`, b),
exportPenaltiesCSV: async (id) => {
const res = await fetch(apiURL(`/api/competitions/${id}/penalties.csv`), { credentials: "include" });
if (!res.ok) throw new Error("export_failed");
return res.blob();
},
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`), listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
}; };
-1163
View File
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
// Shared helpers used by all pages.
const el = (tag, attrs, ...children) => {
const node = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === "class") node.className = attrs[k];
else if (k === "style") Object.assign(node.style, attrs[k]);
else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]);
else if (k === "checked" || k === "disabled" || k === "selected") {
if (attrs[k]) node.setAttribute(k, "");
} else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) {
node.setAttribute(k, attrs[k]);
}
}
}
for (const c of children.flat()) {
if (c === null || c === undefined || c === false) continue;
node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c);
}
return node;
};
function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function naturalCompare(a, b) {
if (a == null) a = "";
if (b == null) b = "";
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
}
// Page navigation helpers (true multi-page navigation, not SPA routing).
const PAGES = {
login: "login.html",
competitions: "competitions.html",
competition: "competition.html",
forcePassword: "force-password.html",
};
function navigate(page, params) {
let url = PAGES[page] || page;
if (params) {
const qs = new URLSearchParams(params).toString();
if (qs) url += "?" + qs;
}
location.assign(url);
}
// Bootstraps a page: loads the current user, enforces auth rules.
// options:
// requireAuth: true — redirect to login if no session
// forbidIfMustChange: true — redirect to force-password.html
// onlyIfMustChange: true — redirect AWAY if user must NOT change
async function bootstrapAuth(options) {
options = options || {};
let user = null;
try {
user = await API.me();
} catch (e) {
user = null;
}
if (options.requireAuth && !user) {
navigate("login");
return null;
}
if (!user) return null;
setLang(user.language || CURRENT_LANG);
if (user.must_change_password && options.forbidIfMustChange) {
navigate("forcePassword");
return null;
}
if (!user.must_change_password && options.onlyIfMustChange) {
navigate("competitions");
return null;
}
return user;
}
// Standard topbar shown on authenticated pages.
function renderTopbar(user, opts) {
opts = opts || {};
const langSelect = el("select",
{ onchange: async (e) => {
const lang = e.target.value;
try { await API.updateMe({ language: lang }); } catch (_) {}
user.language = lang;
setLang(lang);
location.reload();
} },
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
try { await API.logout(); } catch (_) {}
navigate("login");
} }, t("logout"));
const brand = el("a", { href: PAGES.competitions, class: "brand" }, "Penalty Tracker");
const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal(user) },
user.display_name || user.username);
return el("div", { class: "topbar" },
brand,
el("div", { class: "nav" },
opts.extra || null,
profileBtn,
langSelect,
logoutBtn,
)
);
}
// Self-contained profile modal: language and password only. Username/display
// name are read-only here (only system admin can change them).
function openProfileModal(user) {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
const lang = el("select", null,
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l]))
);
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
const usernameField = el("input", { type: "text", value: user.username, disabled: true });
const displayField = el("input", { type: "text", value: user.display_name || "", disabled: true });
const modal = el("div", { class: "modal" },
el("h3", null, t("profile")),
el("div", { class: "field" }, el("label", null, t("username")), usernameField,
el("div", { class: "muted small" }, t("profile_username_readonly"))),
el("div", { class: "field" }, el("label", null, t("display_name")), displayField,
el("div", { class: "muted small" }, t("profile_displayname_readonly"))),
el("div", { class: "field" }, el("label", null, t("language")), lang),
el("div", { class: "field" }, el("label", null, t("new_password")), password),
err, ok,
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
err.style.display = "none"; ok.style.display = "none";
const body = {};
if (lang.value !== user.language) body.language = lang.value;
if (password.value) body.password = password.value;
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
try {
const u = await API.updateMe(body);
if (u.language !== user.language) {
user.language = u.language;
setLang(u.language);
}
ok.textContent = t("saved");
ok.style.display = "block";
setTimeout(() => { backdrop.remove(); location.reload(); }, 600);
} catch (e) {
err.textContent = (e.data && e.data.error) || e.message || "error";
err.style.display = "block";
}
} }, t("save")),
),
);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
}
// Read URL search params as an object.
function queryParams() {
const out = {};
const sp = new URLSearchParams(location.search);
for (const [k, v] of sp.entries()) out[k] = v;
return out;
}
// Initialize language from saved/browser preference before authenticated state
// is known.
(function initInitialLang() {
const userLang = navigator.language ? navigator.language.slice(0, 2) : "en";
if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) {
setLang(userLang);
} else {
setLang("en");
}
})();
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Competition</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/competition.js"></script>
</body>
</html>
+1232
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Competitions</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/competitions.js"></script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
(async function () {
const root = document.getElementById("app");
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
if (!user) return;
const state = { competitions: [], users: [] };
async function loadCompetitions() {
state.competitions = await API.listCompetitions();
}
async function loadUsers() {
if (user.is_system_admin) state.users = await API.listUsers();
}
function openCompetitionModal() {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const nameInput = el("input", { type: "text" });
const allowInput = el("input", { type: "checkbox" });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("new_competition")),
el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput),
el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
if (!nameInput.value.trim()) return;
try {
await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked });
backdrop.remove();
await loadCompetitions();
render();
} catch (e) { alert(e.message); }
} }, t("create")),
),
));
document.body.appendChild(backdrop);
}
function openUserModal() {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const username = el("input", { type: "text",
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const password = el("input", { type: "password" });
const displayName = el("input", { type: "text" });
const langSelect = el("select", null,
...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l]))
);
const isAdmin = el("input", { type: "checkbox" });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("add_user")),
el("div", { class: "field" }, el("label", null, t("username")), username),
el("div", { class: "field" }, el("label", null, t("password")), password),
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
el("div", { class: "field" }, el("label", null, t("language")), langSelect),
el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
if (!username.value.trim() || !password.value) return;
try {
await API.createUser({
username: username.value.trim().toLowerCase(),
password: password.value,
display_name: displayName.value,
language: langSelect.value,
is_system_admin: isAdmin.checked,
});
backdrop.remove();
await loadUsers();
render();
} catch (err) { alert(err.message); }
} }, t("create")),
),
));
document.body.appendChild(backdrop);
}
function renderUsersAdmin() {
const card = el("div", { class: "card" });
card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
el("h2", { style: { margin: 0 } }, t("users")),
el("button", { onclick: openUserModal }, t("add_user")),
));
if (state.users.length === 0) {
card.appendChild(el("div", { class: "muted" }, "—"));
return card;
}
const table = el("table");
table.appendChild(el("thead", null,
el("tr", null,
el("th", null, t("username")),
el("th", null, t("display_name")),
el("th", null, t("language")),
el("th", null, t("is_admin")),
el("th", null, t("must_change_password")),
el("th", null, t("actions")),
)
));
const tbody = el("tbody");
for (const u of state.users) {
const row = el("tr", null,
el("td", null, u.username),
el("td", null, u.display_name),
el("td", null, I18N_NAMES[u.language] || u.language),
el("td", null, u.is_system_admin ? t("yes") : t("no")),
el("td", null, u.must_change_password ? el("span", { class: "badge warn" }, t("yes")) : t("no")),
el("td", null,
el("button", { class: "action-btn", onclick: () => openEditUserModal(u) }, t("edit")),
!u.must_change_password && el("button", { class: "action-btn", onclick: async () => {
if (!confirm(t("confirm_force_password"))) return;
await API.updateUser(u.id, { must_change_password: true });
await loadUsers();
render();
} }, t("force_password_change")),
u.id !== user.id && el("button", { class: "action-btn danger", onclick: async () => {
if (!confirm(t("confirm_delete"))) return;
await API.deleteUser(u.id);
await loadUsers();
render();
} }, t("delete")),
),
);
tbody.appendChild(row);
}
table.appendChild(tbody);
card.appendChild(el("div", { class: "table-wrap" }, table));
return card;
}
function openEditUserModal(u) {
const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
const username = el("input", { type: "text", value: u.username,
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const displayName = el("input", { type: "text", value: u.display_name || "" });
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
const isAdmin = el("input", { type: "checkbox", checked: !!u.is_system_admin });
backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("edit") + ": " + u.username),
el("div", { class: "field" }, el("label", null, t("username")), username),
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
el("div", { class: "field" }, el("label", null, t("new_password")), password),
u.id !== user.id && el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => {
const body = {};
const newUsername = username.value.trim().toLowerCase();
if (newUsername && newUsername !== u.username) body.username = newUsername;
if (displayName.value !== (u.display_name || "")) body.display_name = displayName.value;
if (password.value) body.password = password.value;
if (u.id !== user.id && isAdmin.checked !== !!u.is_system_admin) body.is_system_admin = isAdmin.checked;
if (Object.keys(body).length === 0) { backdrop.remove(); return; }
try {
await API.updateUser(u.id, body);
backdrop.remove();
await loadUsers();
render();
} catch (e) { alert((e.data && e.data.error) || e.message); }
} }, t("save")),
),
));
document.body.appendChild(backdrop);
}
function render() {
clearNode(root);
root.appendChild(renderTopbar(user));
const container = el("div", { class: "container" });
container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
el("h2", { style: { margin: 0 } }, t("competitions")),
user.is_system_admin && el("button", { class: "primary", onclick: openCompetitionModal }, t("new_competition")),
));
if (state.competitions.length === 0) {
container.appendChild(el("div", { class: "muted" }, t("no_competitions")));
} else {
const grid = el("div", { class: "grid" });
for (const c of state.competitions) {
grid.appendChild(el("div", { class: "card" },
el("h2", null, c.name),
el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
el("span", { class: "badge accent" }, t(c.role)),
),
el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")),
));
}
container.appendChild(grid);
}
if (user.is_system_admin) {
container.appendChild(renderUsersAdmin());
}
root.appendChild(container);
}
await loadCompetitions();
await loadUsers();
render();
})();
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Change password</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/force-password.js"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
(async function () {
const root = document.getElementById("app");
const user = await bootstrapAuth({ requireAuth: true, onlyIfMustChange: true });
if (!user) return;
const pw1 = el("input", { type: "password", autocomplete: "new-password" });
const pw2 = el("input", { type: "password", autocomplete: "new-password" });
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
try { await API.logout(); } catch (_) {}
navigate("login");
} }, t("logout"));
async function submit(e) {
e.preventDefault();
err.style.display = "none";
if (pw1.value.length < 6) {
err.textContent = t("password_too_short");
err.style.display = "block";
return;
}
if (pw1.value !== pw2.value) {
err.textContent = t("passwords_dont_match");
err.style.display = "block";
return;
}
try {
const u = await API.updateMe({ password: pw1.value });
if (u && !u.must_change_password) navigate("competitions");
} catch (e) {
err.textContent = (e.data && e.data.error) || e.message || "error";
err.style.display = "block";
}
}
const form = el("form", { onsubmit: submit, class: "col" },
el("h1", null, t("change_password")),
el("p", { class: "muted" }, t("force_password_explain")),
el("div", { class: "field" }, el("label", null, t("new_password")), pw1),
el("div", { class: "field" }, el("label", null, t("repeat_password")), pw2),
err,
el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "0.5rem" } },
logoutBtn,
el("button", { type: "submit", class: "primary" }, t("save")),
),
);
root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
pw1.focus();
})();
+86
View File
@@ -52,6 +52,49 @@ const I18N_DATA = {
username_taken: "Username already taken", username_taken: "Username already taken",
prior_penalties: "Prior penalties for this pilot and rule", prior_penalties: "Prior penalties for this pilot and rule",
none: "None", none: "None",
applied: "Applied",
apply_by_task: "Apply by task",
apply_by_task_explain: "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.",
apply_n_open: "Apply {n} open",
confirm_apply_task: "Mark all {n} open penalties for task '{task}' as applied?",
summary: "Summary",
penalty_summary: "Penalty summary",
rule: "Rule",
rule_not_found: "rule not found",
close: "Close",
count: "#",
count_hint: "Number of prior penalties for this pilot and rule",
prior_count: "Prior count (this pilot & rule)",
search_penalties: "Search penalties…",
filter_all: "All",
filter_open: "Open only",
filter_applied: "Applied only",
total: "Total", open: "Open",
repeat_password: "Repeat password",
password_too_short: "Password must be at least 6 characters",
passwords_dont_match: "Passwords do not match",
too_many_attempts: "Too many login attempts — please wait a few minutes",
profile_username_readonly: "Username can only be changed by a system administrator",
profile_displayname_readonly: "Display name can only be changed by a system administrator",
must_change_password: "Must change password",
confirm_force_password: "Force this user to change their password on next request?",
force_password_change: "Force password change",
force_password_explain: "An administrator has required you to set a new password before you can continue.",
user_not_found: "User not found",
show_incidents: "Show incidents",
hide_incidents: "Hide incidents",
apply_select_task: "Pick a task to start applying its open penalties.",
no_open_penalties: "No open penalties to apply.",
no_task: "(no task)",
start_apply: "Start",
step_x_of_y: "Pilot {x} of {y}",
pilot: "Pilot",
next: "Next",
to_overview: "To overview",
apply_overview: "Overview",
apply_overview_explain: "{n} penalty/penalties will be marked applied on save.",
nothing_to_apply: "Nothing to apply.",
confirm_save_partial: "Save and apply {n} penalty/penalties reviewed so far?",
}, },
de: { de: {
login_title: "Anmelden", login_title: "Anmelden",
@@ -100,6 +143,49 @@ const I18N_DATA = {
username_taken: "Benutzername bereits vergeben", username_taken: "Benutzername bereits vergeben",
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel", prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
none: "Keine", none: "Keine",
applied: "Angewendet",
apply_by_task: "Pro Task anwenden",
apply_by_task_explain: "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.",
apply_n_open: "{n} offene anwenden",
confirm_apply_task: "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?",
summary: "Übersicht",
penalty_summary: "Strafen-Übersicht",
rule: "Regel",
rule_not_found: "Regel nicht gefunden",
close: "Schließen",
count: "#",
count_hint: "Anzahl früherer Strafen für diesen Piloten und diese Regel",
prior_count: "Frühere Anzahl (dieser Pilot & Regel)",
search_penalties: "Strafen durchsuchen…",
filter_all: "Alle",
filter_open: "Nur offene",
filter_applied: "Nur angewendete",
total: "Gesamt", open: "Offen",
repeat_password: "Passwort wiederholen",
password_too_short: "Passwort muss mindestens 6 Zeichen lang sein",
passwords_dont_match: "Passwörter stimmen nicht überein",
too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten",
profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden",
profile_displayname_readonly: "Der Anzeigename kann nur vom Systemadministrator geändert werden",
must_change_password: "Passwortwechsel erforderlich",
confirm_force_password: "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?",
force_password_change: "Passwortwechsel erzwingen",
force_password_explain: "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.",
user_not_found: "Benutzer nicht gefunden",
show_incidents: "Vorfälle anzeigen",
hide_incidents: "Vorfälle ausblenden",
apply_select_task: "Aufgabe wählen, deren offene Strafen angewendet werden sollen.",
no_open_penalties: "Keine offenen Strafen zum Anwenden.",
no_task: "(ohne Aufgabe)",
start_apply: "Start",
step_x_of_y: "Pilot {x} von {y}",
pilot: "Pilot",
next: "Weiter",
to_overview: "Zur Übersicht",
apply_overview: "Übersicht",
apply_overview_explain: "Beim Speichern werden {n} Strafe(n) als angewendet markiert.",
nothing_to_apply: "Nichts anzuwenden.",
confirm_save_partial: "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?",
}, },
pl: { pl: {
login_title: "Zaloguj się", login_title: "Zaloguj się",
+11 -2
View File
@@ -7,10 +7,19 @@
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<div id="app"></div> <div class="loading-wrap"><div class="muted"></div></div>
<script src="/config.js"></script> <script src="/config.js"></script>
<script src="/i18n.js"></script> <script src="/i18n.js"></script>
<script src="/api.js"></script> <script src="/api.js"></script>
<script src="/app.js"></script> <script src="/common.js"></script>
<script>
(async () => {
let user = null;
try { user = await API.me(); } catch (e) {}
if (!user) navigate("login");
else if (user.must_change_password) navigate("forcePassword");
else navigate("competitions");
})();
</script>
</body> </body>
</html> </html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Penalty Tracker — Sign in</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="/config.js"></script>
<script src="/i18n.js"></script>
<script src="/api.js"></script>
<script src="/common.js"></script>
<script src="/login.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
(async function () {
const root = document.getElementById("app");
// If already signed in, skip the login form.
try {
const u = await API.me();
if (u) {
navigate(u.must_change_password ? "forcePassword" : "competitions");
return;
}
} catch (e) {}
function render() {
clearNode(root);
const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username"),
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") });
const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
const langSelect = el("select",
{ onchange: (e) => { setLang(e.target.value); render(); } },
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l]))
);
async function doLogin(e) {
e.preventDefault();
errorBox.style.display = "none";
try {
const u = await API.login(usernameInput.value, passwordInput.value);
if (u.must_change_password) navigate("forcePassword");
else navigate("competitions");
} catch (err) {
if (err.status === 429) errorBox.textContent = t("too_many_attempts");
else if (err.status === 401) errorBox.textContent = t("invalid_credentials");
else errorBox.textContent = err.message || t("invalid_credentials");
errorBox.style.display = "block";
}
}
const form = el("form", { onsubmit: doLogin, class: "col" },
el("h1", null, t("login_title")),
el("div", { class: "field" }, el("label", null, t("username")), usernameInput),
el("div", { class: "field" }, el("label", null, t("password")), passwordInput),
errorBox,
el("button", { type: "submit", class: "primary" }, t("sign_in")),
el("div", { class: "field", style: { marginTop: "0.5rem" } },
el("label", null, t("language")), langSelect
),
);
root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
usernameInput.focus();
}
render();
})();
+17
View File
@@ -397,3 +397,20 @@ textarea { resize: vertical; min-height: 60px; }
.topbar { padding: 0.5rem 0.75rem; } .topbar { padding: 0.5rem 0.75rem; }
.container { padding: 0.75rem; } .container { padding: 0.75rem; }
} }
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.muted.small,
.small { font-size: 0.75rem; }
.apply-row { gap: 1rem; }
h4 { margin: 0.75rem 0 0.25rem 0; font-size: 0.95rem; }
input[disabled],
button[disabled] { opacity: 0.55; cursor: not-allowed; }
+11 -1
View File
@@ -10,7 +10,17 @@ import (
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// Non-browser client without Origin header — allow.
return true
}
if originAllowed(origin) {
return true
}
return sameOriginRequest(r)
},
} }
type wsMessage struct { type wsMessage struct {