package main import ( "encoding/csv" "encoding/json" "io" "net/http" "strconv" "strings" ) // sanitizePilotField trims whitespace, strips a leading CSV-formula prefix // (=, +, -, @, tab, CR) so the value can never be interpreted as a formula // when re-exported in a spreadsheet, and clips to the given byte length. func sanitizePilotField(s string, max int) string { s = strings.TrimSpace(s) for len(s) > 0 { switch s[0] { case '=', '+', '-', '@', '\t', '\r': s = strings.TrimLeftFunc(s[1:], func(r rune) bool { return r == '=' || r == '+' || r == '-' || r == '@' || r == '\t' || r == '\r' || r == ' ' }) continue } break } if len(s) > max { s = s[:max] } return s } 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 } if len(p.Number) > 32 || len(p.LastName) > 128 || len(p.FirstName) > 128 || len(p.Country) > 64 || len(p.BalloonID) > 64 { writeError(w, http.StatusBadRequest, "too_long") return } res, err := db.Exec( "INSERT INTO pilots(competition_id,number,last_name,first_name,country,balloon_id) VALUES(?,?,?,?,?,?)", id, p.Number, p.LastName, p.FirstName, p.Country, p.BalloonID, ) 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 } // Cap the upload to keep memory bounded. body, err := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024)) 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 := sanitizePilotField(rec[0], 32) lastName := sanitizePilotField(rec[1], 128) firstName := sanitizePilotField(rec[2], 128) country := "" balloon := "" if len(rec) >= 4 { country = sanitizePilotField(rec[3], 64) } if len(rec) >= 5 { balloon = sanitizePilotField(rec[4], 64) } 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}) }