Replaced one-pager with multiple pages and fixed security bugs
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
/penaltytracker.db
|
||||
/penaltytracker.db-shm
|
||||
/penaltytracker.db-wal
|
||||
/config.json
|
||||
|
||||
Generated
+15
@@ -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>
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user"
|
||||
const sessionCookie = "pt_session"
|
||||
const sessionDuration = 24 * time.Hour * 14
|
||||
|
||||
const maxUsernameLen = 64
|
||||
const maxDisplayNameLen = 128
|
||||
const maxPasswordLen = 256
|
||||
const maxFieldLen = 2000
|
||||
|
||||
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("GET /api/me", requireAuth(handleMe))
|
||||
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
|
||||
@@ -40,7 +47,7 @@ func ensureDefaultAdmin() error {
|
||||
return err
|
||||
}
|
||||
_, 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",
|
||||
)
|
||||
return err
|
||||
@@ -54,6 +61,79 @@ func newToken() (string, error) {
|
||||
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) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
@@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Username = normalizeUsername(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing_credentials")
|
||||
return
|
||||
}
|
||||
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen {
|
||||
writeError(w, http.StatusBadRequest, "too_long")
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
var hash string
|
||||
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
|
||||
if err != nil {
|
||||
loginRecord(clientIP(r))
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
||||
loginRecord(clientIP(r))
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||
return
|
||||
}
|
||||
@@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
|
||||
}
|
||||
if crossSiteCookies {
|
||||
cookie.SameSite = http.SameSiteNoneMode
|
||||
@@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
|
||||
}
|
||||
if crossSiteCookies {
|
||||
clear.SameSite = http.SameSiteNoneMode
|
||||
@@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
u := userFromCtx(r)
|
||||
var req struct {
|
||||
Username *string `json:"username"`
|
||||
Language *string `json:"language"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Password *string `json:"password"`
|
||||
Language *string `json:"language"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||
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 _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error")
|
||||
lang := strings.TrimSpace(*req.Language)
|
||||
if len(lang) > 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_language")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
|
||||
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "hash_error")
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func loadUser(id int64) (*User, error) {
|
||||
u := &User{}
|
||||
var admin int
|
||||
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id).
|
||||
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
|
||||
var admin, mustChange int
|
||||
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, &mustChange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.IsSystemAdmin = admin == 1
|
||||
u.MustChangePassword = mustChange == 1
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) {
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := authUser(r)
|
||||
@@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if u.MustChangePassword && !passwordChangeExempt(r) {
|
||||
writeError(w, http.StatusForbidden, "password_change_required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, u)
|
||||
h.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "missing_name")
|
||||
return
|
||||
}
|
||||
if len(req.Name) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "too_long")
|
||||
return
|
||||
}
|
||||
allow := 0
|
||||
if req.AllowAnyScorerEdit {
|
||||
allow = 1
|
||||
|
||||
@@ -88,5 +88,13 @@ func migrate() error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -142,9 +144,11 @@ func main() {
|
||||
registerRuleRoutes(mux)
|
||||
registerWSRoutes(mux)
|
||||
|
||||
handler := withSecurityHeaders(withLog(withCSRF(withCORS(mux))))
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: withLog(withCORS(mux)),
|
||||
Handler: handler,
|
||||
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 {
|
||||
if origin == "" {
|
||||
return false
|
||||
@@ -186,6 +206,65 @@ func originAllowed(origin string) bool {
|
||||
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 {
|
||||
if len(corsOrigins) == 0 {
|
||||
return next
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package main
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Language string `json:"language"`
|
||||
IsSystemAdmin bool `json:"is_system_admin"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Language string `json:"language"`
|
||||
IsSystemAdmin bool `json:"is_system_admin"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
type Competition struct {
|
||||
|
||||
+99
-8
@@ -9,12 +9,91 @@ import (
|
||||
"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) {
|
||||
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
|
||||
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
|
||||
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
|
||||
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
|
||||
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) {
|
||||
@@ -92,6 +171,13 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||
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
|
||||
if pen.Transferred {
|
||||
transferred = 1
|
||||
@@ -171,31 +257,31 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) {
|
||||
args := []any{}
|
||||
if req.Flight != nil {
|
||||
sets = append(sets, "flight=?")
|
||||
args = append(args, *req.Flight)
|
||||
args = append(args, clip(*req.Flight, 64))
|
||||
}
|
||||
if req.Date != nil {
|
||||
sets = append(sets, "date=?")
|
||||
args = append(args, *req.Date)
|
||||
args = append(args, clip(*req.Date, 32))
|
||||
}
|
||||
if req.PilotNumber != nil {
|
||||
sets = append(sets, "pilot_number=?")
|
||||
args = append(args, *req.PilotNumber)
|
||||
args = append(args, clip(*req.PilotNumber, 32))
|
||||
}
|
||||
if req.RuleNumber != nil {
|
||||
sets = append(sets, "rule_number=?")
|
||||
args = append(args, *req.RuleNumber)
|
||||
args = append(args, clip(*req.RuleNumber, 64))
|
||||
}
|
||||
if req.Task != nil {
|
||||
sets = append(sets, "task=?")
|
||||
args = append(args, *req.Task)
|
||||
args = append(args, clip(*req.Task, 64))
|
||||
}
|
||||
if req.PenaltiesText != nil {
|
||||
sets = append(sets, "penalties_text=?")
|
||||
args = append(args, *req.PenaltiesText)
|
||||
args = append(args, clip(*req.PenaltiesText, 256))
|
||||
}
|
||||
if req.Description != nil {
|
||||
sets = append(sets, "description=?")
|
||||
args = append(args, *req.Description)
|
||||
args = append(args, clip(*req.Description, maxFieldLen))
|
||||
}
|
||||
if req.Transferred != nil {
|
||||
v := 0
|
||||
@@ -287,7 +373,12 @@ func handleExportPenalties(w http.ResponseWriter, r *http.Request) {
|
||||
if transferred == 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()
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ func handleCreatePilot(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||
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(
|
||||
"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,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -17,7 +16,7 @@ func registerUserRoutes(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "db_error")
|
||||
return
|
||||
@@ -26,9 +25,10 @@ func handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
out := []User{}
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var admin int
|
||||
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
|
||||
var admin, mustChange int
|
||||
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
|
||||
u.IsSystemAdmin = admin == 1
|
||||
u.MustChangePassword = mustChange == 1
|
||||
out = append(out, u)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
@@ -47,11 +47,16 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Username = normalizeUsername(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||
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 {
|
||||
writeError(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
@@ -114,18 +119,39 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Password *string `json:"password"`
|
||||
IsSystemAdmin *bool `json:"is_system_admin"`
|
||||
Username *string `json:"username"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
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 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||
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 len(*req.DisplayName) > maxDisplayNameLen {
|
||||
writeError(w, http.StatusBadRequest, "too_long")
|
||||
return
|
||||
}
|
||||
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
+8
-2
@@ -22,13 +22,14 @@ async function api(method, path, body) {
|
||||
}
|
||||
|
||||
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"),
|
||||
me: () => api("GET", "/api/me"),
|
||||
updateMe: (b) => api("PATCH", "/api/me", b),
|
||||
|
||||
listUsers: () => api("GET", "/api/users"),
|
||||
createUser: (b) => api("POST", "/api/users", b),
|
||||
updateUser: (id, b) => api("PATCH", `/api/users/${id}`, b),
|
||||
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
|
||||
|
||||
listCompetitions: () => api("GET", "/api/competitions"),
|
||||
@@ -60,7 +61,12 @@ const API = {
|
||||
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
|
||||
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
|
||||
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) : ""}`),
|
||||
};
|
||||
|
||||
-1163
File diff suppressed because it is too large
Load Diff
+186
@@ -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");
|
||||
}
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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
@@ -52,6 +52,49 @@ const I18N_DATA = {
|
||||
username_taken: "Username already taken",
|
||||
prior_penalties: "Prior penalties for this pilot and rule",
|
||||
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: {
|
||||
login_title: "Anmelden",
|
||||
@@ -100,6 +143,49 @@ const I18N_DATA = {
|
||||
username_taken: "Benutzername bereits vergeben",
|
||||
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
|
||||
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: {
|
||||
login_title: "Zaloguj się",
|
||||
|
||||
+11
-2
@@ -7,10 +7,19 @@
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div class="loading-wrap"><div class="muted">…</div></div>
|
||||
<script src="/config.js"></script>
|
||||
<script src="/i18n.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>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -397,3 +397,20 @@ textarea { resize: vertical; min-height: 60px; }
|
||||
.topbar { padding: 0.5rem 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; }
|
||||
|
||||
@@ -10,7 +10,17 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user