Add basic web app and API integration
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/penalty-tracker
|
||||||
|
/penaltytracker.db
|
||||||
|
/penaltytracker.db-shm
|
||||||
|
/penaltytracker.db-wal
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CsvFileAttributes">
|
||||||
|
<option name="attributeMap">
|
||||||
|
<map>
|
||||||
|
<entry key="/README.md">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/de.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/en.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/es.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/fr.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/pl.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/pt.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
<entry key="/rules/ru.csv">
|
||||||
|
<value>
|
||||||
|
<Attribute>
|
||||||
|
<option name="separator" value="," />
|
||||||
|
</Attribute>
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/PenaltyTracker.iml" filepath="$PROJECT_DIR$/.idea/PenaltyTracker.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Penalty Tracker
|
||||||
|
|
||||||
|
A small, self-contained system for tracking competition penalties (e.g. for hot-air balloon competitions).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **System Admin** can create competitions and manage all users.
|
||||||
|
- **Chief Scorer** (per competition) manages pilots, members, settings, and can export penalties as CSV. Chief Scorers can promote others to Chief Scorer or Scorer.
|
||||||
|
- **Scorer** (per competition) adds penalties. Scorers can only edit/delete their own entries unless the competition setting "allow any scorer to edit" is on.
|
||||||
|
- Pilots can be added manually or imported from CSV (`number,last_name,first_name,country,balloon_id`).
|
||||||
|
- Rule catalogues per language are loaded from `rules/*.csv`. The penalty form lets you search rules by number or text and shows the suggested penalty plus repeat-behaviour (same / doubled / escalates).
|
||||||
|
- All open clients receive **live updates** for a competition via WebSocket — works for 30+ concurrent users on a single SQLite process.
|
||||||
|
- The penalty table is **client-side sortable** by every column and updates **in-place without flicker** when new rows arrive.
|
||||||
|
- Multi-lingual UI: English, German, Polish, Russian, French, Spanish, Portuguese (set per user in user settings).
|
||||||
|
- Clean black / white design with `#2b6cb0` accent, max border-radius `0.375rem`.
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o penaltytracker .
|
||||||
|
./penaltytracker
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8080>.
|
||||||
|
|
||||||
|
**Default login (created automatically on first start):**
|
||||||
|
|
||||||
|
```
|
||||||
|
username: admin
|
||||||
|
password: admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the password immediately under the user menu.
|
||||||
|
|
||||||
|
## Configuration (env vars)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|-----------------|----------------------|--------------------------------------------------------------------------|
|
||||||
|
| `ADDR` | `:8080` | HTTP listen address |
|
||||||
|
| `DB_PATH` | `penaltytracker.db` | SQLite database path |
|
||||||
|
| `RULES_DIR` | `rules` | Directory containing per-language rule CSV files |
|
||||||
|
| `WEB_DIR` | `web` | Directory containing the frontend static files |
|
||||||
|
| `CORS_ORIGINS` | *(empty)* | Comma-separated allowed frontend origins (e.g. `http://127.0.0.1:36823,https://app.example.com`). When set, CORS headers are added. Use `*` to allow any origin. |
|
||||||
|
| `COOKIE_CROSS_SITE` | *(empty)* | Set to `true` only when the frontend runs on a different *site* (different registrable domain) than the API and you need cross-site cookies. Switches the session cookie to `SameSite=None; Secure` — requires HTTPS on both sides. Leave unset for same-host different-port setups (e.g. `127.0.0.1:36823 → 127.0.0.1:8080`); `SameSite=Lax` already works there. |
|
||||||
|
|
||||||
|
### Frontend backend URL
|
||||||
|
|
||||||
|
If you host the frontend on a different origin than the API, open the login screen and fill in **Backend URL** (e.g. `http://192.168.0.10:8080` or `https://api.example.com`). It is stored in `localStorage` (`api_base`). Leave it empty to use the same origin as the page. You can also pre-set it by editing `web/config.js` (`DEFAULT_API_BASE`).
|
||||||
|
|
||||||
|
Cross-origin cookies require **HTTPS on both sides** (so the `Secure` cookie flag is accepted). For LAN testing on plain HTTP, host the frontend and backend on the same origin (the default).
|
||||||
|
|
||||||
|
## Rules CSV format
|
||||||
|
|
||||||
|
`rules/<lang>.csv` — one file per language code (`en`, `de`, `pl`, `ru`, `fr`, `es`, `pt`).
|
||||||
|
|
||||||
|
```csv
|
||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R1.1,Late at briefing,Warning,escalate:Warning|50 CP|100 CP|DSQ
|
||||||
|
R3.1,Unsafe flying,200 CP,doubled
|
||||||
|
R4.2,Incorrect declaration,No Result,same
|
||||||
|
```
|
||||||
|
|
||||||
|
`escalation_mode`:
|
||||||
|
- `same` — penalty stays the same on repeat.
|
||||||
|
- `doubled` — penalty doubles each time.
|
||||||
|
- `escalate:tier1|tier2|tier3|...` — penalty climbs through the listed tiers.
|
||||||
|
|
||||||
|
To reload rules without restart, POST `/api/rules/reload` as a system admin.
|
||||||
|
|
||||||
|
## Pilot import CSV format
|
||||||
|
|
||||||
|
```csv
|
||||||
|
number,last_name,first_name,country,balloon_id
|
||||||
|
1,Doe,John,DE,D-OABC
|
||||||
|
2,Müller,Anna,DE,
|
||||||
|
```
|
||||||
|
|
||||||
|
Header row is optional and auto-detected.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Storage is a single SQLite file in WAL mode — easy to back up by copying the `.db` file (along with `-wal` / `-shm` if present) while the server is briefly stopped.
|
||||||
|
- The frontend is pure HTML/CSS/JS, no build step.
|
||||||
|
- The Go binary embeds nothing — it serves `./web` and `./rules` from the working directory.
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const userCtxKey ctxKey = "user"
|
||||||
|
|
||||||
|
const sessionCookie = "pt_session"
|
||||||
|
const sessionDuration = 24 * time.Hour * 14
|
||||||
|
|
||||||
|
func registerAuthRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /api/login", handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/logout", handleLogout)
|
||||||
|
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
|
||||||
|
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDefaultAdmin() error {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.Exec(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)",
|
||||||
|
"admin", string(hash), "System Admin", "en",
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_credentials")
|
||||||
|
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 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "token_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(sessionDuration)
|
||||||
|
if _, err := db.Exec("INSERT INTO sessions(token,user_id,expires_at) VALUES(?,?,?)", token, id, expires.Format(time.RFC3339)); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "session_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expires,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
if crossSiteCookies {
|
||||||
|
cookie.SameSite = http.SameSiteNoneMode
|
||||||
|
cookie.Secure = true
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
user, _ := loadUser(id)
|
||||||
|
writeJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err == nil {
|
||||||
|
db.Exec("DELETE FROM sessions WHERE token=?", c.Value)
|
||||||
|
}
|
||||||
|
clear := &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
if crossSiteCookies {
|
||||||
|
clear.SameSite = http.SameSiteNoneMode
|
||||||
|
clear.Secure = true
|
||||||
|
}
|
||||||
|
http.SetCookie(w, clear)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := userFromCtx(r)
|
||||||
|
writeJSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Password != nil && *req.Password != "" {
|
||||||
|
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 {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, _ := loadUser(u.ID)
|
||||||
|
writeJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.IsSystemAdmin = admin == 1
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authUser(r *http.Request) (*User, error) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("no_session")
|
||||||
|
}
|
||||||
|
var userID int64
|
||||||
|
var expiresAt string
|
||||||
|
err = db.QueryRow("SELECT user_id, expires_at FROM sessions WHERE token=?", c.Value).Scan(&userID, &expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid_session")
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, expiresAt); err == nil && time.Now().After(t) {
|
||||||
|
db.Exec("DELETE FROM sessions WHERE token=?", c.Value)
|
||||||
|
return nil, errors.New("expired")
|
||||||
|
}
|
||||||
|
return loadUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAuth(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, err := authUser(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userCtxKey, u)
|
||||||
|
h.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdmin(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := userFromCtx(r)
|
||||||
|
if !u.IsSystemAdmin {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFromCtx(r *http.Request) *User {
|
||||||
|
v := r.Context().Value(userCtxKey)
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.(*User)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if body != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCompetitionRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/competitions", requireAuth(handleListCompetitions))
|
||||||
|
mux.HandleFunc("POST /api/competitions", requireAdmin(handleCreateCompetition))
|
||||||
|
mux.HandleFunc("GET /api/competitions/{id}", requireAuth(handleGetCompetition))
|
||||||
|
mux.HandleFunc("PATCH /api/competitions/{id}", requireAuth(handleUpdateCompetition))
|
||||||
|
mux.HandleFunc("DELETE /api/competitions/{id}", requireAdmin(handleDeleteCompetition))
|
||||||
|
mux.HandleFunc("GET /api/competitions/{id}/members", requireAuth(handleListMembers))
|
||||||
|
mux.HandleFunc("POST /api/competitions/{id}/members", requireAuth(handleAddMember))
|
||||||
|
mux.HandleFunc("DELETE /api/competitions/{id}/members/{uid}", requireAuth(handleRemoveMember))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userRole(competitionID, userID int64) (string, error) {
|
||||||
|
var role string
|
||||||
|
err := db.QueryRow("SELECT role FROM competition_users WHERE competition_id=? AND user_id=?", competitionID, userID).Scan(&role)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func canAccessCompetition(u *User, competitionID int64) (string, bool) {
|
||||||
|
if u.IsSystemAdmin {
|
||||||
|
return "system_admin", true
|
||||||
|
}
|
||||||
|
role, err := userRole(competitionID, u.ID)
|
||||||
|
if err != nil || role == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return role, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u := userFromCtx(r)
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if u.IsSystemAdmin {
|
||||||
|
rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions ORDER BY created_at DESC")
|
||||||
|
} else {
|
||||||
|
rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.created_at,cu.role
|
||||||
|
FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id
|
||||||
|
WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Competition{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c Competition
|
||||||
|
var allow int
|
||||||
|
if u.IsSystemAdmin {
|
||||||
|
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
|
||||||
|
c.Role = "system_admin"
|
||||||
|
} else {
|
||||||
|
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt, &c.Role)
|
||||||
|
}
|
||||||
|
c.AllowAnyScorerEdit = allow == 1
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allow := 0
|
||||||
|
if req.AllowAnyScorerEdit {
|
||||||
|
allow = 1
|
||||||
|
}
|
||||||
|
res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit) VALUES(?,?)", req.Name, allow)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit}
|
||||||
|
writeJSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetCompetition(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 {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var c Competition
|
||||||
|
var allow int
|
||||||
|
err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions WHERE id=?", id).
|
||||||
|
Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AllowAnyScorerEdit = allow == 1
|
||||||
|
c.Role = role
|
||||||
|
writeJSON(w, http.StatusOK, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateCompetition(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 != "system_admin" && role != "chief_scorer") {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
db.Exec("UPDATE competitions SET name=? WHERE id=?", *req.Name, id)
|
||||||
|
}
|
||||||
|
if req.AllowAnyScorerEdit != nil {
|
||||||
|
v := 0
|
||||||
|
if *req.AllowAnyScorerEdit {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id)
|
||||||
|
}
|
||||||
|
hub.broadcast(id, "competition_updated", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteCompetition(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
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM competitions WHERE id=?", id)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListMembers(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)
|
||||||
|
if _, ok := canAccessCompetition(u, id); !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT u.id,u.username,u.display_name,cu.role
|
||||||
|
FROM competition_users cu JOIN users u ON u.id=cu.user_id
|
||||||
|
WHERE cu.competition_id=? ORDER BY cu.role,u.username`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []CompetitionUser{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m CompetitionUser
|
||||||
|
rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role)
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAddMember(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 != "system_admin" && role != "chief_scorer") {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role != "chief_scorer" && req.Role != "scorer" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_role")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = db.Exec("INSERT OR REPLACE INTO competition_users(competition_id,user_id,role) VALUES(?,?,?)", id, req.UserID, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRemoveMember(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
|
||||||
|
}
|
||||||
|
uid, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := userFromCtx(r)
|
||||||
|
role, ok := canAccessCompetition(u, id)
|
||||||
|
if !ok || (role != "system_admin" && role != "chief_scorer") {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM competition_users WHERE competition_id=? AND user_id=?", id, uid)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
func openDB(path string) error {
|
||||||
|
d, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.SetMaxOpenConns(1)
|
||||||
|
if err := d.Ping(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
db = d
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate() error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
is_system_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS competitions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
allow_any_scorer_edit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS competition_users (
|
||||||
|
competition_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (competition_id, user_id),
|
||||||
|
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS pilots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
competition_id INTEGER NOT NULL,
|
||||||
|
number TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
balloon_id TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(competition_id, number),
|
||||||
|
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS penalties (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
competition_id INTEGER NOT NULL,
|
||||||
|
flight TEXT NOT NULL DEFAULT '',
|
||||||
|
date TEXT NOT NULL DEFAULT '',
|
||||||
|
pilot_number TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_number TEXT NOT NULL DEFAULT '',
|
||||||
|
task TEXT NOT NULL DEFAULT '',
|
||||||
|
penalties_text TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
transferred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY(competition_id) REFERENCES competitions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE RESTRICT
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_penalties_competition ON penalties(competition_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_pilots_competition ON pilots(competition_id)`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module penaltytracker
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
|
modernc.org/sqlite v1.50.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
modernc.org/gc/v3 v3.1.3 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/strutil v1.2.1 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
DBPath string `json:"db_path"`
|
||||||
|
RulesDir string `json:"rules_dir"`
|
||||||
|
CORSOrigins []string `json:"cors_origins"`
|
||||||
|
CrossSiteCookies bool `json:"cross_site_cookies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var corsOrigins []string
|
||||||
|
var crossSiteCookies bool
|
||||||
|
|
||||||
|
func defaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Addr: ":8080",
|
||||||
|
DBPath: "penaltytracker.db",
|
||||||
|
RulesDir: "rules",
|
||||||
|
CORSOrigins: []string{},
|
||||||
|
CrossSiteCookies: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(path string, cfg *Config) error {
|
||||||
|
if dir := filepath.Dir(path); dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (*Config, error) {
|
||||||
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err := writeConfig(path, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("config file created at %s with defaults", path)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cfg.Addr == "" {
|
||||||
|
cfg.Addr = ":8080"
|
||||||
|
}
|
||||||
|
if cfg.DBPath == "" {
|
||||||
|
cfg.DBPath = "penaltytracker.db"
|
||||||
|
}
|
||||||
|
if cfg.RulesDir == "" {
|
||||||
|
cfg.RulesDir = "rules"
|
||||||
|
}
|
||||||
|
for i, o := range cfg.CORSOrigins {
|
||||||
|
cfg.CORSOrigins[i] = strings.TrimSpace(o)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDir(dir string) error {
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDirectories(cfg *Config) error {
|
||||||
|
if err := ensureDir(cfg.RulesDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dbDir := filepath.Dir(cfg.DBPath); dbDir != "" {
|
||||||
|
if err := ensureDir(dbDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.json", "path to config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := loadConfig(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureDirectories(cfg); err != nil {
|
||||||
|
log.Fatalf("ensure directories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
corsOrigins = cfg.CORSOrigins
|
||||||
|
crossSiteCookies = cfg.CrossSiteCookies
|
||||||
|
|
||||||
|
if err := openDB(cfg.DBPath); err != nil {
|
||||||
|
log.Fatalf("db open: %v", err)
|
||||||
|
}
|
||||||
|
if err := migrate(); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := ensureDefaultAdmin(); err != nil {
|
||||||
|
log.Fatalf("default admin: %v", err)
|
||||||
|
}
|
||||||
|
if err := loadRules(cfg.RulesDir); err != nil {
|
||||||
|
log.Printf("rules load warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub = newHub()
|
||||||
|
go hub.run()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
registerAuthRoutes(mux)
|
||||||
|
registerUserRoutes(mux)
|
||||||
|
registerCompetitionRoutes(mux)
|
||||||
|
registerPilotRoutes(mux)
|
||||||
|
registerPenaltyRoutes(mux)
|
||||||
|
registerRuleRoutes(mux)
|
||||||
|
registerWSRoutes(mux)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.Addr,
|
||||||
|
Handler: withLog(withCORS(mux)),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("listening on %s (cors_origins=%v)", cfg.Addr, corsOrigins)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLog(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func originAllowed(origin string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, o := range corsOrigins {
|
||||||
|
if o == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(o, origin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCORS(next http.Handler) http.Handler {
|
||||||
|
if len(corsOrigins) == 0 {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if originAllowed(origin) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
|
||||||
|
reqHeaders := r.Header.Get("Access-Control-Request-Headers")
|
||||||
|
if reqHeaders == "" {
|
||||||
|
reqHeaders = "Content-Type"
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Competition struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompetitionUser struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pilot struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CompetitionID int64 `json:"competition_id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
BalloonID string `json:"balloon_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Penalty struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CompetitionID int64 `json:"competition_id"`
|
||||||
|
Flight string `json:"flight"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
PilotNumber string `json:"pilot_number"`
|
||||||
|
PilotName string `json:"pilot_name"`
|
||||||
|
RuleNumber string `json:"rule_number"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
PenaltiesText string `json:"penalties_text"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedBy int64 `json:"created_by"`
|
||||||
|
CreatedByName string `json:"created_by_name"`
|
||||||
|
Transferred bool `json:"transferred"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
SuggestedPenalty string `json:"suggested_penalty"`
|
||||||
|
EscalationMode string `json:"escalation_mode"`
|
||||||
|
EscalationTiers []string `json:"escalation_tiers,omitempty"`
|
||||||
|
}
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPenalty(id int64) (*Penalty, error) {
|
||||||
|
row := db.QueryRow(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number,
|
||||||
|
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
|
||||||
|
p.rule_number,p.task,p.penalties_text,p.description,p.created_by,
|
||||||
|
COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at
|
||||||
|
FROM penalties p
|
||||||
|
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
|
||||||
|
LEFT JOIN users u ON u.id=p.created_by
|
||||||
|
WHERE p.id=?`, id)
|
||||||
|
var pen Penalty
|
||||||
|
var transferred int
|
||||||
|
err := row.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
|
||||||
|
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
|
||||||
|
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pen.Transferred = transferred == 1
|
||||||
|
return &pen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListPenalties(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)
|
||||||
|
if _, ok := canAccessCompetition(u, id); !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT p.id,p.competition_id,p.flight,p.date,p.pilot_number,
|
||||||
|
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
|
||||||
|
p.rule_number,p.task,p.penalties_text,p.description,p.created_by,
|
||||||
|
COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at
|
||||||
|
FROM penalties p
|
||||||
|
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
|
||||||
|
LEFT JOIN users u ON u.id=p.created_by
|
||||||
|
WHERE p.competition_id=? ORDER BY p.id DESC`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Penalty{}
|
||||||
|
for rows.Next() {
|
||||||
|
var pen Penalty
|
||||||
|
var transferred int
|
||||||
|
rows.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
|
||||||
|
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
|
||||||
|
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
|
||||||
|
pen.Transferred = transferred == 1
|
||||||
|
out = append(out, pen)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreatePenalty(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 != "scorer" && role != "chief_scorer" && role != "system_admin") {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pen Penalty
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&pen); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
transferred := 0
|
||||||
|
if pen.Transferred {
|
||||||
|
transferred = 1
|
||||||
|
}
|
||||||
|
res, err := db.Exec(`INSERT INTO penalties(competition_id,flight,date,pilot_number,rule_number,task,penalties_text,description,created_by,transferred)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, pen.Flight, pen.Date, pen.PilotNumber, pen.RuleNumber, pen.Task, pen.PenaltiesText, pen.Description, u.ID, transferred)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pid, _ := res.LastInsertId()
|
||||||
|
created, err := loadPenalty(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "load_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.broadcast(id, "penalty_created", created)
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEditPenalty(u *User, competitionID, createdBy int64) bool {
|
||||||
|
role, ok := canAccessCompetition(u, competitionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if role == "system_admin" || role == "chief_scorer" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if role == "scorer" {
|
||||||
|
if u.ID == createdBy {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var allow int
|
||||||
|
db.QueryRow("SELECT allow_any_scorer_edit FROM competitions WHERE id=?", competitionID).Scan(&allow)
|
||||||
|
return allow == 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdatePenalty(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
|
||||||
|
}
|
||||||
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := userFromCtx(r)
|
||||||
|
var createdBy int64
|
||||||
|
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canEditPenalty(u, id, createdBy) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Flight *string `json:"flight"`
|
||||||
|
Date *string `json:"date"`
|
||||||
|
PilotNumber *string `json:"pilot_number"`
|
||||||
|
RuleNumber *string `json:"rule_number"`
|
||||||
|
Task *string `json:"task"`
|
||||||
|
PenaltiesText *string `json:"penalties_text"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Transferred *bool `json:"transferred"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sets := []string{}
|
||||||
|
args := []any{}
|
||||||
|
if req.Flight != nil {
|
||||||
|
sets = append(sets, "flight=?")
|
||||||
|
args = append(args, *req.Flight)
|
||||||
|
}
|
||||||
|
if req.Date != nil {
|
||||||
|
sets = append(sets, "date=?")
|
||||||
|
args = append(args, *req.Date)
|
||||||
|
}
|
||||||
|
if req.PilotNumber != nil {
|
||||||
|
sets = append(sets, "pilot_number=?")
|
||||||
|
args = append(args, *req.PilotNumber)
|
||||||
|
}
|
||||||
|
if req.RuleNumber != nil {
|
||||||
|
sets = append(sets, "rule_number=?")
|
||||||
|
args = append(args, *req.RuleNumber)
|
||||||
|
}
|
||||||
|
if req.Task != nil {
|
||||||
|
sets = append(sets, "task=?")
|
||||||
|
args = append(args, *req.Task)
|
||||||
|
}
|
||||||
|
if req.PenaltiesText != nil {
|
||||||
|
sets = append(sets, "penalties_text=?")
|
||||||
|
args = append(args, *req.PenaltiesText)
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
sets = append(sets, "description=?")
|
||||||
|
args = append(args, *req.Description)
|
||||||
|
}
|
||||||
|
if req.Transferred != nil {
|
||||||
|
v := 0
|
||||||
|
if *req.Transferred {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
sets = append(sets, "transferred=?")
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
if len(sets) > 0 {
|
||||||
|
sets = append(sets, "updated_at=datetime('now')")
|
||||||
|
args = append(args, pid)
|
||||||
|
query := "UPDATE penalties SET " + strings.Join(sets, ",") + " WHERE id=?"
|
||||||
|
if _, err := db.Exec(query, args...); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, err := loadPenalty(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "load_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.broadcast(id, "penalty_updated", updated)
|
||||||
|
writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeletePenalty(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
|
||||||
|
}
|
||||||
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := userFromCtx(r)
|
||||||
|
var createdBy int64
|
||||||
|
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !canEditPenalty(u, id, createdBy) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM penalties WHERE id=?", pid)
|
||||||
|
hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExportPenalties(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
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT p.id,p.flight,p.date,p.pilot_number,
|
||||||
|
COALESCE(pl.last_name || ', ' || pl.first_name, ''),
|
||||||
|
p.rule_number,p.task,p.penalties_text,p.description,
|
||||||
|
COALESCE(u.display_name, u.username),p.transferred,p.created_at
|
||||||
|
FROM penalties p
|
||||||
|
LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number
|
||||||
|
LEFT JOIN users u ON u.id=p.created_by
|
||||||
|
WHERE p.competition_id=? ORDER BY p.id`, id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"penalties_%d.csv\"", id))
|
||||||
|
cw := csv.NewWriter(w)
|
||||||
|
cw.Write([]string{"id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "task", "penalties", "description", "created_by", "transferred", "created_at"})
|
||||||
|
for rows.Next() {
|
||||||
|
var idv int64
|
||||||
|
var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt string
|
||||||
|
var transferred int
|
||||||
|
rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt)
|
||||||
|
t := "0"
|
||||||
|
if transferred == 1 {
|
||||||
|
t = "1"
|
||||||
|
}
|
||||||
|
cw.Write([]string{strconv.FormatInt(idv, 10), flight, date, pnum, pname, rnum, task, pens, desc, creator, t, createdAt})
|
||||||
|
}
|
||||||
|
cw.Flush()
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPilotRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots))
|
||||||
|
mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot))
|
||||||
|
mux.HandleFunc("PATCH /api/competitions/{id}/pilots/{pid}", requireAuth(handleUpdatePilot))
|
||||||
|
mux.HandleFunc("DELETE /api/competitions/{id}/pilots/{pid}", requireAuth(handleDeletePilot))
|
||||||
|
mux.HandleFunc("POST /api/competitions/{id}/pilots/import", requireAuth(handleImportPilots))
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireChiefOrAdmin(r *http.Request, competitionID int64) bool {
|
||||||
|
u := userFromCtx(r)
|
||||||
|
role, ok := canAccessCompetition(u, competitionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return role == "system_admin" || role == "chief_scorer"
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListPilots(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)
|
||||||
|
if _, ok := canAccessCompetition(u, id); !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.Query("SELECT id,competition_id,number,last_name,first_name,country,balloon_id FROM pilots WHERE competition_id=? ORDER BY number", id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []Pilot{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Pilot
|
||||||
|
rows.Scan(&p.ID, &p.CompetitionID, &p.Number, &p.LastName, &p.FirstName, &p.Country, &p.BalloonID)
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreatePilot(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
|
||||||
|
}
|
||||||
|
if !requireChiefOrAdmin(r, id) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p Pilot
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Number = strings.TrimSpace(p.Number)
|
||||||
|
if p.Number == "" || p.LastName == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "duplicate_number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.ID, _ = res.LastInsertId()
|
||||||
|
p.CompetitionID = id
|
||||||
|
hub.broadcast(id, "pilot_changed", nil)
|
||||||
|
writeJSON(w, http.StatusCreated, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdatePilot(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
|
||||||
|
}
|
||||||
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireChiefOrAdmin(r, id) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p Pilot
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = db.Exec(
|
||||||
|
"UPDATE pilots SET number=?,last_name=?,first_name=?,country=?,balloon_id=? WHERE id=? AND competition_id=?",
|
||||||
|
p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, pid, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.broadcast(id, "pilot_changed", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeletePilot(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
|
||||||
|
}
|
||||||
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireChiefOrAdmin(r, id) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec("DELETE FROM pilots WHERE id=? AND competition_id=?", pid, id)
|
||||||
|
hub.broadcast(id, "pilot_changed", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImportPilots(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
|
||||||
|
}
|
||||||
|
if !requireChiefOrAdmin(r, id) {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "read_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader := csv.NewReader(strings.NewReader(string(body)))
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_csv")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "tx_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
stmt, _ := tx.Prepare(`INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id)
|
||||||
|
VALUES(?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(competition_id,number) DO UPDATE SET
|
||||||
|
last_name=excluded.last_name, first_name=excluded.first_name,
|
||||||
|
country=excluded.country, balloon_id=excluded.balloon_id`)
|
||||||
|
defer stmt.Close()
|
||||||
|
count := 0
|
||||||
|
for i, rec := range records {
|
||||||
|
if i == 0 {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(rec[0]))
|
||||||
|
if lower == "number" || lower == "nummer" || lower == "no" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rec) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
number := strings.TrimSpace(rec[0])
|
||||||
|
lastName := strings.TrimSpace(rec[1])
|
||||||
|
firstName := strings.TrimSpace(rec[2])
|
||||||
|
country := ""
|
||||||
|
balloon := ""
|
||||||
|
if len(rec) >= 4 {
|
||||||
|
country = strings.TrimSpace(rec[3])
|
||||||
|
}
|
||||||
|
if len(rec) >= 5 {
|
||||||
|
balloon = strings.TrimSpace(rec[4])
|
||||||
|
}
|
||||||
|
if number == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := stmt.Exec(id, number, lastName, firstName, country, balloon); err == nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "commit_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.broadcast(id, "pilot_changed", nil)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"imported": count})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
rulesMu sync.RWMutex
|
||||||
|
rules = map[string]map[string]Rule{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadRules(dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := map[string]map[string]Rule{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".csv") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lang := strings.TrimSuffix(e.Name(), ".csv")
|
||||||
|
f, err := os.Open(filepath.Join(dir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := csv.NewReader(f)
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
records, err := r.ReadAll()
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
langMap := map[string]Rule{}
|
||||||
|
for i, rec := range records {
|
||||||
|
if len(rec) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(rec[0]))
|
||||||
|
if lower == "number" || lower == "rule_number" || lower == "rule" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ru := Rule{
|
||||||
|
Number: strings.TrimSpace(rec[0]),
|
||||||
|
Text: strings.TrimSpace(rec[1]),
|
||||||
|
SuggestedPenalty: strings.TrimSpace(rec[2]),
|
||||||
|
EscalationMode: strings.TrimSpace(rec[3]),
|
||||||
|
}
|
||||||
|
if ru.EscalationMode == "" {
|
||||||
|
ru.EscalationMode = "same"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ru.EscalationMode, "escalate:") {
|
||||||
|
tiers := strings.Split(strings.TrimPrefix(ru.EscalationMode, "escalate:"), "|")
|
||||||
|
for j, t := range tiers {
|
||||||
|
tiers[j] = strings.TrimSpace(t)
|
||||||
|
}
|
||||||
|
ru.EscalationTiers = tiers
|
||||||
|
ru.EscalationMode = "escalate"
|
||||||
|
}
|
||||||
|
langMap[ru.Number] = ru
|
||||||
|
}
|
||||||
|
out[lang] = langMap
|
||||||
|
}
|
||||||
|
rulesMu.Lock()
|
||||||
|
rules = out
|
||||||
|
rulesMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRules(lang string) []Rule {
|
||||||
|
rulesMu.RLock()
|
||||||
|
defer rulesMu.RUnlock()
|
||||||
|
m, ok := rules[lang]
|
||||||
|
if !ok {
|
||||||
|
m = rules["en"]
|
||||||
|
}
|
||||||
|
out := make([]Rule, 0, len(m))
|
||||||
|
for _, r := range m {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerRuleRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
|
||||||
|
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListRules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lang := r.URL.Query().Get("lang")
|
||||||
|
if lang == "" {
|
||||||
|
u := userFromCtx(r)
|
||||||
|
lang = u.Language
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, getRules(lang))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleReloadRules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dir := os.Getenv("RULES_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir = "rules"
|
||||||
|
}
|
||||||
|
if err := loadRules(dir); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "load_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Wettbewerbsnummer,,same
|
||||||
|
R3.8 Wrng.,Wettbewerbsnummer - Warnung,Warning,same
|
||||||
|
R6.3.2,FRF verspätet,bis zu 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF verspätet - Warnung,Warning,same
|
||||||
|
R7.5,PZ-Verletzung PZ##,bis zu 1000 CP,same
|
||||||
|
R7.8,Unzulässige Deklaration,bis zu 100 TP,same
|
||||||
|
R8.4.2,Aufgabenreihenfolge,bis zu 1000 TP,same
|
||||||
|
R8.4.7d,Falsches Logger-Ziel,25 TP,same
|
||||||
|
R8.4.7m,Falscher Marker,25 TP,same
|
||||||
|
R8.4.8,"Zwei Marker innerhalb MMA, gewertet auf LM",auf elektronischen Mark gewertet,same
|
||||||
|
R8.12,Verspäteter Eintritt,"50 TP je angef. Min. (>5 Min), 100 TP wenn <5 Min",same
|
||||||
|
R9.2.2,Landeigentümer-Erlaubnis,bis zu 250 TP,same
|
||||||
|
R9.3.1,Missachten des Startleiters,bis zu 200 TP,same
|
||||||
|
R9.3.2,Schnellverschluss nicht benutzt,,same
|
||||||
|
R9.4.1,Mehr als 1 Fahrzeug im Startbereich,100 TP,same
|
||||||
|
R9.4.3,Fahrzeug fährt nach gelber Flagge in den Startbereich,100 TP,same
|
||||||
|
R9.9,Startperiode überschritten,50 TP je angef. Minute,same
|
||||||
|
R10.1.3,Ballonkollision,bis zu 1000 CP + RFS,same
|
||||||
|
R10.2.1,Gefährliches Fliegen,bis zur Disqualifikation + RFS,doubled
|
||||||
|
R10.2.2,Überschreitung der vertikalen Geschwindigkeitsgrenze,BSA + RFS,same
|
||||||
|
R10.5,Rücksichtsloses Verhalten,bis zu 1000 CP,same
|
||||||
|
R10.6,Vieh stören oder Feldfrüchte beschädigen,bis zu 1000 CP,same
|
||||||
|
R10.8,Kollision,bis zu 500 CP,same
|
||||||
|
R10.11,Fahrweise,bis zu 500 CP,same
|
||||||
|
R11.4,Bodenkontakt 1,200 TP,same
|
||||||
|
R11.5l,Bodenkontakt 2 - leicht,100 TP,same
|
||||||
|
R11.5s,Bodenkontakt 2 - massiv,500 TP,same
|
||||||
|
R12.3.5,Verspätete Deklaration,50 TP je angef. Minute,same
|
||||||
|
R12.5,Veränderte oder nicht zugelassene Marker,bis zu 250 TP,same
|
||||||
|
R12.7,GMD nicht in Ordnung,50 TP,same
|
||||||
|
R12.7a,GMD nicht in Ordnung (+50m),+50m,same
|
||||||
|
R12.8,MKR nicht entrollt,50 TP,same
|
||||||
|
R12.8a,MKR nicht entrollt (+50m),+50m,same
|
||||||
|
R12.13.3,Außerhalb der Wertungsperiode,NR,same
|
||||||
|
R13.3,Distanzverletzung,"2 TP je 0,1% Verletzung (ANG, ELB, LRN: Summe der % Verletzung)",same
|
||||||
|
R13.3>,Distanzverletzung >25%,NR,same
|
||||||
|
R13.3.1,Start zu nah am Ziel,"2 TP je 0,1% Verletzung",same
|
||||||
|
R13.3.3,Landung zu nah am Ziel oder MMA,200 TP,same
|
||||||
|
R15.1,Deklaration ungültig / nicht konform,,same
|
||||||
|
R15.5.2,Keine gültige Deklaration,,same
|
||||||
|
R15.12.2,Zeitlimit überschritten,,same
|
||||||
|
R15.15,Benachbarte Segmente,,same
|
||||||
|
RII.18.d,BalloonLive- oder BLS-Handhabung,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,44 @@
|
|||||||
|
rule_number,rule_text,suggested_penalty,escalation_mode
|
||||||
|
R3.8,Competition number,,same
|
||||||
|
R3.8 Wrng.,Competition number - Warning,Warning,same
|
||||||
|
R6.3.2,FRF delayed,up to 100 TP,same
|
||||||
|
R6.3.2 Wrng.,FRF delayed - Warning,Warning,same
|
||||||
|
R7.5,PZ Infringement PZ##,up to 1000 CP,same
|
||||||
|
R7.8,Inappropriate declaration,up to 100 TP,same
|
||||||
|
R8.4.2,Task Order,up to 1000 TP,same
|
||||||
|
R8.4.7d,Wrong Loggergoal,25 TP,same
|
||||||
|
R8.4.7m,Wrong Marker,25 TP,same
|
||||||
|
R8.4.8,"Two markers inside MMA, scored to LM",scored to electronic mark,same
|
||||||
|
R8.12,Late Entry,"50 TP per part minute (>5 min), 100 TP if <5 min",same
|
||||||
|
R9.2.2,Landowner Permission,up to 250 TP,same
|
||||||
|
R9.3.1,Disregard launch master,up to 200 TP,same
|
||||||
|
R9.3.2,Quick Release not used,,same
|
||||||
|
R9.4.1,More than 1 vehicle in launch area,100 TP,same
|
||||||
|
R9.4.3,Vehicle entering launch area after yellow flag,100 TP,same
|
||||||
|
R9.9,Launch period exceeded,50 TP per part minute,same
|
||||||
|
R10.1.3,Balloon Collision,up to 1000 CP + RFS,same
|
||||||
|
R10.2.1,Dangerous flying,up to disqualification + RFS,doubled
|
||||||
|
R10.2.2,Exceeding vertical speed limits,BSA + RFS,same
|
||||||
|
R10.5,Inconsiderate behaviour,up to 1000 CP,same
|
||||||
|
R10.6,Disturbing livestock or damaging crop,up to 1000 CP,same
|
||||||
|
R10.8,Collision,up to 500 CP,same
|
||||||
|
R10.11,Way of driving,up to 500 CP,same
|
||||||
|
R11.4,Ground Contact 1,200 TP,same
|
||||||
|
R11.5l,Ground Contact 2 - Light,100 TP,same
|
||||||
|
R11.5s,Ground Contact 2 - Solid,500 TP,same
|
||||||
|
R12.3.5,Late declaration,50 TP per part minute,same
|
||||||
|
R12.5,Modified or unauthorized markers,up to 250 TP,same
|
||||||
|
R12.7,GMD not ok,50 TP,same
|
||||||
|
R12.7a,GMD not ok (+50m),+50m,same
|
||||||
|
R12.8,MKR not unrolled,50 TP,same
|
||||||
|
R12.8a,MKR not unrolled (+50m),+50m,same
|
||||||
|
R12.13.3,Out of scoring period,NR,same
|
||||||
|
R13.3,Distance Infringement,"2 TP per 0.1% infringement (ANG, ELB, LRN: sum of % infringement)",same
|
||||||
|
R13.3>,Distance Infringement >25%,NR,same
|
||||||
|
R13.3.1,Take-off too close to goal,2 TP per 0.1% infringement,same
|
||||||
|
R13.3.3,Landing too close to goal or MMA,200 TP,same
|
||||||
|
R15.1,Declaration invalid / non compliant,,same
|
||||||
|
R15.5.2,No valid declaration,,same
|
||||||
|
R15.12.2,Exceeded Timing limit,,same
|
||||||
|
R15.15,Adjacent segments,,same
|
||||||
|
RII.18.d,BalloonLive or BLS handling,,same
|
||||||
|
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerUserRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/users", requireAdmin(handleListUsers))
|
||||||
|
mux.HandleFunc("POST /api/users", requireAuth(handleCreateUser))
|
||||||
|
mux.HandleFunc("DELETE /api/users/{id}", requireAdmin(handleDeleteUser))
|
||||||
|
mux.HandleFunc("PATCH /api/users/{id}", requireAdmin(handleAdminUpdateUser))
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
var admin int
|
||||||
|
rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
|
||||||
|
u.IsSystemAdmin = admin == 1
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor := userFromCtx(r)
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
IsSystemAdmin bool `json:"is_system_admin"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.IsSystemAdmin && !actor.IsSystemAdmin {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !actor.IsSystemAdmin {
|
||||||
|
var chiefCount int
|
||||||
|
db.QueryRow("SELECT COUNT(*) FROM competition_users WHERE user_id=? AND role='chief_scorer'", actor.ID).Scan(&chiefCount)
|
||||||
|
if chiefCount == 0 {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "hash_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Language == "" {
|
||||||
|
req.Language = "en"
|
||||||
|
}
|
||||||
|
admin := 0
|
||||||
|
if req.IsSystemAdmin {
|
||||||
|
admin = 1
|
||||||
|
}
|
||||||
|
res, err := db.Exec(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,?)",
|
||||||
|
req.Username, string(hash), req.DisplayName, req.Language, admin,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "username_taken")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
u, _ := loadUser(id)
|
||||||
|
writeJSON(w, http.StatusCreated, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteUser(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
|
||||||
|
}
|
||||||
|
actor := userFromCtx(r)
|
||||||
|
if actor.ID == id {
|
||||||
|
writeError(w, http.StatusBadRequest, "cannot_delete_self")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("DELETE FROM users WHERE id=?", id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAdminUpdateUser(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
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
IsSystemAdmin *bool `json:"is_system_admin"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
|
||||||
|
}
|
||||||
|
if req.Password != nil && *req.Password != "" {
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
|
db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), id)
|
||||||
|
}
|
||||||
|
if req.IsSystemAdmin != nil {
|
||||||
|
v := 0
|
||||||
|
if *req.IsSystemAdmin {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
db.Exec("UPDATE users SET is_system_admin=? WHERE id=?", v, id)
|
||||||
|
}
|
||||||
|
u, _ := loadUser(id)
|
||||||
|
writeJSON(w, http.StatusOK, u)
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
};
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(apiURL(path), opts);
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
let data = null;
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) {
|
||||||
|
try { data = JSON.parse(text); } catch (e) { data = text; }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error((data && data.error) || res.statusText);
|
||||||
|
err.status = res.status;
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
login: (u, p) => api("POST", "/api/login", { username: u, 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),
|
||||||
|
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
|
||||||
|
|
||||||
|
listCompetitions: () => api("GET", "/api/competitions"),
|
||||||
|
createCompetition: (b) => api("POST", "/api/competitions", b),
|
||||||
|
getCompetition: (id) => api("GET", `/api/competitions/${id}`),
|
||||||
|
updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b),
|
||||||
|
deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`),
|
||||||
|
|
||||||
|
listMembers: (id) => api("GET", `/api/competitions/${id}/members`),
|
||||||
|
addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b),
|
||||||
|
removeMember: (id, uid) => api("DELETE", `/api/competitions/${id}/members/${uid}`),
|
||||||
|
|
||||||
|
listPilots: (id) => api("GET", `/api/competitions/${id}/pilots`),
|
||||||
|
createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b),
|
||||||
|
updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b),
|
||||||
|
deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`),
|
||||||
|
importPilots: async (id, csv) => {
|
||||||
|
const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "text/csv" },
|
||||||
|
body: csv,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("import_failed");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
listPenalties: (id) => api("GET", `/api/competitions/${id}/penalties`),
|
||||||
|
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`),
|
||||||
|
|
||||||
|
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
function openCompetitionWS(id, handlers) {
|
||||||
|
const url = wsURL(`/api/competitions/${id}/ws`);
|
||||||
|
let ws = null;
|
||||||
|
let closed = false;
|
||||||
|
let backoff = 1000;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onopen = () => { backoff = 1000; if (handlers.onopen) handlers.onopen(); };
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (handlers.onmessage) handlers.onmessage(msg);
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (handlers.onclose) handlers.onclose();
|
||||||
|
if (!closed) {
|
||||||
|
setTimeout(connect, backoff);
|
||||||
|
backoff = Math.min(backoff * 2, 15000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return {
|
||||||
|
close: () => { closed = true; try { ws && ws.close(); } catch (e) {} }
|
||||||
|
};
|
||||||
|
}
|
||||||
+1163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
const API_BASE = "http://127.0.0.1:8080";
|
||||||
|
|
||||||
|
function getApiBase() {
|
||||||
|
return API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiURL(path) {
|
||||||
|
const base = getApiBase();
|
||||||
|
if (!base) return path;
|
||||||
|
return base + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsURL(path) {
|
||||||
|
const base = getApiBase();
|
||||||
|
if (base) {
|
||||||
|
let u;
|
||||||
|
try { u = new URL(base); } catch (e) { return null; }
|
||||||
|
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
u.pathname = u.pathname.replace(/\/+$/, "") + path;
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${location.host}${path}`;
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
const I18N_AVAILABLE = ["en", "de", "pl", "ru", "fr", "es", "pt"];
|
||||||
|
const I18N_NAMES = {
|
||||||
|
en: "English", de: "Deutsch", pl: "Polski", ru: "Русский",
|
||||||
|
fr: "Français", es: "Español", pt: "Português"
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N_DATA = {
|
||||||
|
en: {
|
||||||
|
login_title: "Sign in",
|
||||||
|
username: "Username", password: "Password", sign_in: "Sign in",
|
||||||
|
invalid_credentials: "Invalid username or password",
|
||||||
|
logout: "Logout", settings: "Settings", language: "Language",
|
||||||
|
competitions: "Competitions", new_competition: "New competition",
|
||||||
|
competition_name: "Competition name", create: "Create", cancel: "Cancel",
|
||||||
|
role: "Role", system_admin: "System Admin", chief_scorer: "Chief Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules",
|
||||||
|
settings_tab: "Settings",
|
||||||
|
number: "Number", last_name: "Last name", first_name: "First name",
|
||||||
|
country: "Country", balloon_id: "Balloon ID",
|
||||||
|
add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV",
|
||||||
|
flight: "Flight", date: "Date", pilot_number: "Pilot No.",
|
||||||
|
pilot_name: "Pilot name", rule: "Rule", task: "Task",
|
||||||
|
penalty_values: "Penalties", description: "Description", created_by: "Created by",
|
||||||
|
transferred: "Transferred", actions: "Actions",
|
||||||
|
add_penalty: "Add penalty", edit: "Edit", delete: "Delete", save: "Save",
|
||||||
|
confirm_delete: "Delete this entry?", search_rule: "Search rule by number or text",
|
||||||
|
suggested_penalty: "Suggested penalty",
|
||||||
|
escalation: "Escalation behavior",
|
||||||
|
escalation_same: "Stays the same", escalation_doubled: "Doubled each time",
|
||||||
|
escalation_escalate: "Escalates: ",
|
||||||
|
add_member: "Add member", remove: "Remove",
|
||||||
|
add_user: "Add user", users: "Users",
|
||||||
|
display_name: "Display name", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Allow any scorer to edit penalties",
|
||||||
|
open: "Open", back: "Back", change_password: "Change password",
|
||||||
|
new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,balloon)",
|
||||||
|
no_pilots: "No pilots yet", no_penalties: "No penalties yet",
|
||||||
|
no_members: "No members yet", no_competitions: "No competitions",
|
||||||
|
select_pilot: "Select pilot", rule_number_short: "Rule No.",
|
||||||
|
transferred_only: "Only untransferred",
|
||||||
|
showing_n_of_m: "Showing {n} of {m}",
|
||||||
|
online: "Online", offline: "Offline",
|
||||||
|
forbidden: "Not allowed",
|
||||||
|
save_settings: "Save settings",
|
||||||
|
saved: "Saved",
|
||||||
|
yes: "Yes", no: "No",
|
||||||
|
backend_url: "Backend URL",
|
||||||
|
backend_url_hint: "Leave empty to use the same origin (e.g. http://192.168.0.10:8080)",
|
||||||
|
profile: "Profile",
|
||||||
|
current_password: "Current password",
|
||||||
|
leave_blank_keep: "Leave blank to keep current",
|
||||||
|
username_taken: "Username already taken",
|
||||||
|
prior_penalties: "Prior penalties for this pilot and rule",
|
||||||
|
none: "None",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
login_title: "Anmelden",
|
||||||
|
username: "Benutzername", password: "Passwort", sign_in: "Anmelden",
|
||||||
|
invalid_credentials: "Falscher Benutzername oder Passwort",
|
||||||
|
logout: "Abmelden", settings: "Einstellungen", language: "Sprache",
|
||||||
|
competitions: "Wettbewerbe", new_competition: "Neuer Wettbewerb",
|
||||||
|
competition_name: "Wettbewerbsname", create: "Erstellen", cancel: "Abbrechen",
|
||||||
|
role: "Rolle", system_admin: "Systemadmin", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln",
|
||||||
|
settings_tab: "Einstellungen",
|
||||||
|
number: "Nummer", last_name: "Nachname", first_name: "Vorname",
|
||||||
|
country: "Land", balloon_id: "Ballon-Kennung",
|
||||||
|
add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren",
|
||||||
|
flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.",
|
||||||
|
pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe",
|
||||||
|
penalty_values: "Strafen", description: "Beschreibung", created_by: "Angelegt von",
|
||||||
|
transferred: "Übertragen", actions: "Aktionen",
|
||||||
|
add_penalty: "Strafe hinzufügen", edit: "Bearbeiten", delete: "Löschen", save: "Speichern",
|
||||||
|
confirm_delete: "Eintrag löschen?", search_rule: "Regel nach Nummer oder Text suchen",
|
||||||
|
suggested_penalty: "Vorgeschlagene Strafe",
|
||||||
|
escalation: "Verhalten bei Wiederholung",
|
||||||
|
escalation_same: "Bleibt gleich", escalation_doubled: "Wird jedes Mal verdoppelt",
|
||||||
|
escalation_escalate: "Höherstufung: ",
|
||||||
|
add_member: "Mitglied hinzufügen", remove: "Entfernen",
|
||||||
|
add_user: "Benutzer anlegen", users: "Benutzer",
|
||||||
|
display_name: "Anzeigename", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten",
|
||||||
|
open: "Öffnen", back: "Zurück", change_password: "Passwort ändern",
|
||||||
|
new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Ballon)",
|
||||||
|
no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen",
|
||||||
|
no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe",
|
||||||
|
select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.",
|
||||||
|
transferred_only: "Nur nicht übertragene",
|
||||||
|
showing_n_of_m: "{n} von {m}",
|
||||||
|
online: "Online", offline: "Offline",
|
||||||
|
forbidden: "Keine Berechtigung",
|
||||||
|
save_settings: "Einstellungen speichern",
|
||||||
|
saved: "Gespeichert",
|
||||||
|
yes: "Ja", no: "Nein",
|
||||||
|
backend_url: "Backend-URL",
|
||||||
|
backend_url_hint: "Leer lassen für gleichen Ursprung (z.B. http://192.168.0.10:8080)",
|
||||||
|
profile: "Profil",
|
||||||
|
current_password: "Aktuelles Passwort",
|
||||||
|
leave_blank_keep: "Leer lassen um beizubehalten",
|
||||||
|
username_taken: "Benutzername bereits vergeben",
|
||||||
|
prior_penalties: "Frühere Strafen für diesen Piloten und diese Regel",
|
||||||
|
none: "Keine",
|
||||||
|
},
|
||||||
|
pl: {
|
||||||
|
login_title: "Zaloguj się",
|
||||||
|
username: "Nazwa użytkownika", password: "Hasło", sign_in: "Zaloguj",
|
||||||
|
invalid_credentials: "Nieprawidłowy login lub hasło",
|
||||||
|
logout: "Wyloguj", settings: "Ustawienia", language: "Język",
|
||||||
|
competitions: "Zawody", new_competition: "Nowe zawody",
|
||||||
|
competition_name: "Nazwa zawodów", create: "Utwórz", cancel: "Anuluj",
|
||||||
|
role: "Rola", system_admin: "Administrator", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady",
|
||||||
|
settings_tab: "Ustawienia",
|
||||||
|
number: "Numer", last_name: "Nazwisko", first_name: "Imię",
|
||||||
|
country: "Kraj", balloon_id: "Oznaczenie balonu",
|
||||||
|
add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV",
|
||||||
|
flight: "Lot", date: "Data", pilot_number: "Nr pilota",
|
||||||
|
pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie",
|
||||||
|
penalty_values: "Kary", description: "Opis", created_by: "Wprowadził",
|
||||||
|
transferred: "Przesłano", actions: "Akcje",
|
||||||
|
add_penalty: "Dodaj karę", edit: "Edytuj", delete: "Usuń", save: "Zapisz",
|
||||||
|
confirm_delete: "Usunąć ten wpis?", search_rule: "Szukaj zasady po numerze lub tekście",
|
||||||
|
suggested_penalty: "Sugerowana kara",
|
||||||
|
escalation: "Zachowanie przy powtórzeniu",
|
||||||
|
escalation_same: "Bez zmian", escalation_doubled: "Podwajana za każdym razem",
|
||||||
|
escalation_escalate: "Eskalacja: ",
|
||||||
|
add_member: "Dodaj członka", remove: "Usuń",
|
||||||
|
add_user: "Dodaj użytkownika", users: "Użytkownicy",
|
||||||
|
display_name: "Wyświetlana nazwa", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary",
|
||||||
|
open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło",
|
||||||
|
new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,balon)",
|
||||||
|
no_pilots: "Brak pilotów", no_penalties: "Brak kar",
|
||||||
|
no_members: "Brak członków", no_competitions: "Brak zawodów",
|
||||||
|
select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady",
|
||||||
|
transferred_only: "Tylko nieprzesłane",
|
||||||
|
showing_n_of_m: "{n} z {m}",
|
||||||
|
online: "Online", offline: "Offline",
|
||||||
|
forbidden: "Brak uprawnień",
|
||||||
|
save_settings: "Zapisz ustawienia",
|
||||||
|
saved: "Zapisano",
|
||||||
|
yes: "Tak", no: "Nie",
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
login_title: "Вход",
|
||||||
|
username: "Имя пользователя", password: "Пароль", sign_in: "Войти",
|
||||||
|
invalid_credentials: "Неверное имя или пароль",
|
||||||
|
logout: "Выйти", settings: "Настройки", language: "Язык",
|
||||||
|
competitions: "Соревнования", new_competition: "Новое соревнование",
|
||||||
|
competition_name: "Название", create: "Создать", cancel: "Отмена",
|
||||||
|
role: "Роль", system_admin: "Администратор", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила",
|
||||||
|
settings_tab: "Настройки",
|
||||||
|
number: "Номер", last_name: "Фамилия", first_name: "Имя",
|
||||||
|
country: "Страна", balloon_id: "Регистрация шара",
|
||||||
|
add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV",
|
||||||
|
flight: "Полёт", date: "Дата", pilot_number: "№ пилота",
|
||||||
|
pilot_name: "Имя пилота", rule: "Правило", task: "Задание",
|
||||||
|
penalty_values: "Штрафы", description: "Описание", created_by: "Автор",
|
||||||
|
transferred: "Передано", actions: "Действия",
|
||||||
|
add_penalty: "Добавить штраф", edit: "Редактировать", delete: "Удалить", save: "Сохранить",
|
||||||
|
confirm_delete: "Удалить запись?", search_rule: "Поиск правила по номеру или тексту",
|
||||||
|
suggested_penalty: "Рекомендованный штраф",
|
||||||
|
escalation: "Поведение при повторе",
|
||||||
|
escalation_same: "Без изменений", escalation_doubled: "Удваивается каждый раз",
|
||||||
|
escalation_escalate: "Эскалация: ",
|
||||||
|
add_member: "Добавить участника", remove: "Удалить",
|
||||||
|
add_user: "Создать пользователя", users: "Пользователи",
|
||||||
|
display_name: "Отображаемое имя", is_admin: "Админ",
|
||||||
|
allow_any_scorer_edit: "Любой Scorer может редактировать штрафы",
|
||||||
|
open: "Открыть", back: "Назад", change_password: "Изменить пароль",
|
||||||
|
new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,шар)",
|
||||||
|
no_pilots: "Нет пилотов", no_penalties: "Нет штрафов",
|
||||||
|
no_members: "Нет участников", no_competitions: "Нет соревнований",
|
||||||
|
select_pilot: "Выберите пилота", rule_number_short: "№ правила",
|
||||||
|
transferred_only: "Только непереданные",
|
||||||
|
showing_n_of_m: "{n} из {m}",
|
||||||
|
online: "Онлайн", offline: "Оффлайн",
|
||||||
|
forbidden: "Нет доступа",
|
||||||
|
save_settings: "Сохранить настройки",
|
||||||
|
saved: "Сохранено",
|
||||||
|
yes: "Да", no: "Нет",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
login_title: "Connexion",
|
||||||
|
username: "Nom d'utilisateur", password: "Mot de passe", sign_in: "Connexion",
|
||||||
|
invalid_credentials: "Identifiants invalides",
|
||||||
|
logout: "Déconnexion", settings: "Paramètres", language: "Langue",
|
||||||
|
competitions: "Compétitions", new_competition: "Nouvelle compétition",
|
||||||
|
competition_name: "Nom", create: "Créer", cancel: "Annuler",
|
||||||
|
role: "Rôle", system_admin: "Administrateur", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles",
|
||||||
|
settings_tab: "Paramètres",
|
||||||
|
number: "Numéro", last_name: "Nom", first_name: "Prénom",
|
||||||
|
country: "Pays", balloon_id: "Immat. ballon",
|
||||||
|
add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV",
|
||||||
|
flight: "Vol", date: "Date", pilot_number: "N° pilote",
|
||||||
|
pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve",
|
||||||
|
penalty_values: "Pénalités", description: "Description", created_by: "Créé par",
|
||||||
|
transferred: "Transféré", actions: "Actions",
|
||||||
|
add_penalty: "Ajouter pénalité", edit: "Modifier", delete: "Supprimer", save: "Enregistrer",
|
||||||
|
confirm_delete: "Supprimer cette entrée ?", search_rule: "Rechercher une règle par numéro ou texte",
|
||||||
|
suggested_penalty: "Pénalité suggérée",
|
||||||
|
escalation: "Comportement en cas de répétition",
|
||||||
|
escalation_same: "Reste identique", escalation_doubled: "Doublée à chaque fois",
|
||||||
|
escalation_escalate: "Escalade : ",
|
||||||
|
add_member: "Ajouter membre", remove: "Retirer",
|
||||||
|
add_user: "Créer un utilisateur", users: "Utilisateurs",
|
||||||
|
display_name: "Nom affiché", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités",
|
||||||
|
open: "Ouvrir", back: "Retour", change_password: "Changer le mot de passe",
|
||||||
|
new_password: "Nouveau mot de passe", csv_paste: "Coller CSV (n°,nom,prénom,pays,ballon)",
|
||||||
|
no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité",
|
||||||
|
no_members: "Aucun membre", no_competitions: "Aucune compétition",
|
||||||
|
select_pilot: "Choisir un pilote", rule_number_short: "N° règle",
|
||||||
|
transferred_only: "Non transférés uniquement",
|
||||||
|
showing_n_of_m: "{n} sur {m}",
|
||||||
|
online: "En ligne", offline: "Hors ligne",
|
||||||
|
forbidden: "Non autorisé",
|
||||||
|
save_settings: "Enregistrer",
|
||||||
|
saved: "Enregistré",
|
||||||
|
yes: "Oui", no: "Non",
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
login_title: "Iniciar sesión",
|
||||||
|
username: "Usuario", password: "Contraseña", sign_in: "Entrar",
|
||||||
|
invalid_credentials: "Usuario o contraseña incorrectos",
|
||||||
|
logout: "Salir", settings: "Ajustes", language: "Idioma",
|
||||||
|
competitions: "Competiciones", new_competition: "Nueva competición",
|
||||||
|
competition_name: "Nombre", create: "Crear", cancel: "Cancelar",
|
||||||
|
role: "Rol", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas",
|
||||||
|
settings_tab: "Ajustes",
|
||||||
|
number: "Número", last_name: "Apellido", first_name: "Nombre",
|
||||||
|
country: "País", balloon_id: "Matrícula globo",
|
||||||
|
add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
||||||
|
flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto",
|
||||||
|
pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea",
|
||||||
|
penalty_values: "Penalizaciones", description: "Descripción", created_by: "Creado por",
|
||||||
|
transferred: "Transferido", actions: "Acciones",
|
||||||
|
add_penalty: "Añadir penalización", edit: "Editar", delete: "Eliminar", save: "Guardar",
|
||||||
|
confirm_delete: "¿Eliminar este registro?", search_rule: "Buscar regla por número o texto",
|
||||||
|
suggested_penalty: "Penalización sugerida",
|
||||||
|
escalation: "Comportamiento al repetirse",
|
||||||
|
escalation_same: "Sin cambios", escalation_doubled: "Se duplica cada vez",
|
||||||
|
escalation_escalate: "Escala: ",
|
||||||
|
add_member: "Añadir miembro", remove: "Quitar",
|
||||||
|
add_user: "Crear usuario", users: "Usuarios",
|
||||||
|
display_name: "Nombre mostrado", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones",
|
||||||
|
open: "Abrir", back: "Atrás", change_password: "Cambiar contraseña",
|
||||||
|
new_password: "Nueva contraseña", csv_paste: "Pegar CSV (n.º,apellido,nombre,país,globo)",
|
||||||
|
no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones",
|
||||||
|
no_members: "Sin miembros", no_competitions: "Sin competiciones",
|
||||||
|
select_pilot: "Elegir piloto", rule_number_short: "N.º regla",
|
||||||
|
transferred_only: "Solo no transferidas",
|
||||||
|
showing_n_of_m: "{n} de {m}",
|
||||||
|
online: "En línea", offline: "Sin conexión",
|
||||||
|
forbidden: "No permitido",
|
||||||
|
save_settings: "Guardar ajustes",
|
||||||
|
saved: "Guardado",
|
||||||
|
yes: "Sí", no: "No",
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
login_title: "Entrar",
|
||||||
|
username: "Utilizador", password: "Palavra-passe", sign_in: "Entrar",
|
||||||
|
invalid_credentials: "Utilizador ou palavra-passe inválidos",
|
||||||
|
logout: "Sair", settings: "Definições", language: "Idioma",
|
||||||
|
competitions: "Competições", new_competition: "Nova competição",
|
||||||
|
competition_name: "Nome", create: "Criar", cancel: "Cancelar",
|
||||||
|
role: "Papel", system_admin: "Administrador", chief_scorer: "Chief-Scorer", scorer: "Scorer",
|
||||||
|
pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras",
|
||||||
|
settings_tab: "Definições",
|
||||||
|
number: "Número", last_name: "Apelido", first_name: "Nome",
|
||||||
|
country: "País", balloon_id: "Matrícula balão",
|
||||||
|
add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
||||||
|
flight: "Voo", date: "Data", pilot_number: "N.º piloto",
|
||||||
|
pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa",
|
||||||
|
penalty_values: "Penalizações", description: "Descrição", created_by: "Criado por",
|
||||||
|
transferred: "Transferido", actions: "Ações",
|
||||||
|
add_penalty: "Adicionar penalização", edit: "Editar", delete: "Eliminar", save: "Guardar",
|
||||||
|
confirm_delete: "Eliminar este registo?", search_rule: "Procurar regra por número ou texto",
|
||||||
|
suggested_penalty: "Penalização sugerida",
|
||||||
|
escalation: "Comportamento em caso de repetição",
|
||||||
|
escalation_same: "Sem alteração", escalation_doubled: "Duplicada de cada vez",
|
||||||
|
escalation_escalate: "Escala: ",
|
||||||
|
add_member: "Adicionar membro", remove: "Remover",
|
||||||
|
add_user: "Criar utilizador", users: "Utilizadores",
|
||||||
|
display_name: "Nome mostrado", is_admin: "Admin",
|
||||||
|
allow_any_scorer_edit: "Qualquer scorer pode editar penalizações",
|
||||||
|
open: "Abrir", back: "Voltar", change_password: "Alterar palavra-passe",
|
||||||
|
new_password: "Nova palavra-passe", csv_paste: "Colar CSV (n.º,apelido,nome,país,balão)",
|
||||||
|
no_pilots: "Sem pilotos", no_penalties: "Sem penalizações",
|
||||||
|
no_members: "Sem membros", no_competitions: "Sem competições",
|
||||||
|
select_pilot: "Escolher piloto", rule_number_short: "N.º regra",
|
||||||
|
transferred_only: "Apenas não transferidas",
|
||||||
|
showing_n_of_m: "{n} de {m}",
|
||||||
|
online: "Online", offline: "Offline",
|
||||||
|
forbidden: "Não autorizado",
|
||||||
|
save_settings: "Guardar definições",
|
||||||
|
saved: "Guardado",
|
||||||
|
yes: "Sim", no: "Não",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let CURRENT_LANG = "en";
|
||||||
|
|
||||||
|
function setLang(lang) {
|
||||||
|
if (!I18N_DATA[lang]) lang = "en";
|
||||||
|
CURRENT_LANG = lang;
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key, vars) {
|
||||||
|
const d = I18N_DATA[CURRENT_LANG] || I18N_DATA.en;
|
||||||
|
let str = d[key] || I18N_DATA.en[key] || key;
|
||||||
|
if (vars) {
|
||||||
|
for (const k in vars) {
|
||||||
|
str = str.replaceAll("{" + k + "}", vars[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Penalty Tracker</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="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
:root {
|
||||||
|
--accent: #2b6cb0;
|
||||||
|
--accent-hover: #2c5282;
|
||||||
|
--bg: #ffffff;
|
||||||
|
--fg: #111111;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--row-hover: #f9fafb;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
transition: background 0.1s ease, border-color 0.1s ease;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
button.danger {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
button.danger:hover { border-color: var(--danger); }
|
||||||
|
button.ghost { border-color: transparent; background: transparent; }
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(43, 108, 176, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.topbar .brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.topbar .nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 1.25rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.col { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.field label { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.login-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.login-box h1 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fafafa;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
th .sort-ind {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
tbody tr:hover { background: var(--row-hover); }
|
||||||
|
tbody tr.transferred { background: #f3f6fb; }
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.badge.accent { background: rgba(43, 108, 176, 0.1); color: var(--accent); border-color: rgba(43, 108, 176, 0.3); }
|
||||||
|
.badge.warn { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.modal h3 { margin-top: 0; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rule-suggestion {
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.rule-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rule-num {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: rgba(43, 108, 176, 0.08);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rule-text { font-size: 0.9rem; line-height: 1.3; }
|
||||||
|
.kv {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.kv .k { color: var(--muted); min-width: 9rem; }
|
||||||
|
.small { font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.tier-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tier-pill {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tier-pill:last-child {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(43, 108, 176, 0.08);
|
||||||
|
}
|
||||||
|
.tier-arrow { color: var(--muted); font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prior-box {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
.prior-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.mini-table { font-size: 12px; }
|
||||||
|
.mini-table th, .mini-table td {
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.mini-table th { background: transparent; position: static; }
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.search-box .results {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%; left: 0; right: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 5;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.search-box .results .item {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.search-box .results .item:last-child { border-bottom: none; }
|
||||||
|
.search-box .results .item:hover { background: #f3f4f6; }
|
||||||
|
.search-box .results .item .num { color: var(--accent); font-weight: 600; margin-right: 0.5rem; }
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.switch .slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
transition: 0.15s;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.switch .slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.15s;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider { background-color: var(--accent); }
|
||||||
|
.switch input:checked + .slider:before { transform: translateX(16px); }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.tabs button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.tabs button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.action-btn:hover { border-color: var(--border); }
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.connection-status.online { background: #10b981; }
|
||||||
|
.connection-status.offline { background: #ef4444; }
|
||||||
|
|
||||||
|
textarea { resize: vertical; min-height: 60px; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.topbar { padding: 0.5rem 0.75rem; }
|
||||||
|
.container { padding: 0.75rem; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
type wsMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload any `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
competitionID int64
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[int64]map[*client]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hub *Hub
|
||||||
|
|
||||||
|
func newHub() *Hub {
|
||||||
|
return &Hub{clients: map[int64]map[*client]struct{}{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) run() {}
|
||||||
|
|
||||||
|
func (h *Hub) register(c *client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
set, ok := h.clients[c.competitionID]
|
||||||
|
if !ok {
|
||||||
|
set = map[*client]struct{}{}
|
||||||
|
h.clients[c.competitionID] = set
|
||||||
|
}
|
||||||
|
set[c] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) unregister(c *client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if set, ok := h.clients[c.competitionID]; ok {
|
||||||
|
if _, ok := set[c]; ok {
|
||||||
|
delete(set, c)
|
||||||
|
close(c.send)
|
||||||
|
}
|
||||||
|
if len(set) == 0 {
|
||||||
|
delete(h.clients, c.competitionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) broadcast(competitionID int64, kind string, payload any) {
|
||||||
|
msg := wsMessage{Type: kind, Payload: payload}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
set, ok := h.clients[competitionID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for c := range set {
|
||||||
|
select {
|
||||||
|
case c.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWSRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/competitions/{id}/ws", requireAuth(handleWS))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWS(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)
|
||||||
|
if _, ok := canAccessCompetition(u, id); !ok {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := &client{conn: conn, competitionID: id, send: make(chan []byte, 16)}
|
||||||
|
hub.register(c)
|
||||||
|
go writePump(c)
|
||||||
|
readPump(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPump(c *client) {
|
||||||
|
defer func() {
|
||||||
|
hub.unregister(c)
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(1024)
|
||||||
|
for {
|
||||||
|
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePump(c *client) {
|
||||||
|
defer c.conn.Close()
|
||||||
|
for msg := range c.send {
|
||||||
|
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user