Files
PenaltyTracker/rules.go
T
CodingPhoenix e077703d5d Fix rules CSV parsing and add rule text editing API
Strip a leading UTF-8 BOM before parsing rule CSVs (it was causing the
header row to be misread as a rule) and skip rows that clearly failed
CSV parsing (e.g. a whole line landing in the rule_number field after
a spreadsheet app double-encoded a quoted cell) instead of silently
loading garbage.

Add PUT /api/rules/{lang}/{number} and GET /api/rules/numbers so rule
text, suggested penalty and escalation mode can be edited per language
from the admin UI, always writing the CSV back out with correct
quoting via encoding/csv. Also fixes two bugs found while wiring this
up: rulesDir() ignored the configured rules_dir (falling back to a
RULES_DIR env var that's never set), and the CORS middleware didn't
allow PUT.
2026-09-05 13:32:46 +02:00

274 lines
7.0 KiB
Go

package main
import (
"bufio"
"encoding/csv"
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"unicode/utf8"
)
var (
rulesMu sync.RWMutex
rules = map[string]map[string]Rule{}
)
var langNameRe = regexp.MustCompile(`^[a-z]{2,5}$`)
func rulesDir() string {
if configuredRulesDir != "" {
return configuredRulesDir
}
return "rules"
}
// readCSVFile reads a CSV file, transparently stripping a leading UTF-8 BOM
// (common when the file has been round-tripped through a spreadsheet app).
func readCSVFile(path string) ([][]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
br := bufio.NewReader(f)
if bom, err := br.Peek(3); err == nil && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF {
br.Discard(3)
}
r := csv.NewReader(br)
r.FieldsPerRecord = -1
return r.ReadAll()
}
// writeCSVFile writes records back out with correct quoting, which is what
// prevents the "double-encoded row" corruption spreadsheet apps tend to cause.
func writeCSVFile(path string, records [][]string) error {
f, err := os.Create(path)
if err != nil {
return err
}
w := csv.NewWriter(f)
if err := w.WriteAll(records); err != nil {
f.Close()
return err
}
w.Flush()
if err := w.Error(); err != nil {
f.Close()
return err
}
return f.Close()
}
// upsertRuleRecord updates the row for `number` in-place, or appends a new
// row if it isn't present yet. records[0] is assumed to be the header.
func upsertRuleRecord(records [][]string, number, text, penalty, escalation string) [][]string {
for i, rec := range records {
if i == 0 || len(rec) < 1 {
continue
}
if strings.TrimSpace(rec[0]) != number {
continue
}
for len(rec) < 4 {
rec = append(rec, "")
}
rec[1], rec[2], rec[3] = text, penalty, escalation
records[i] = rec
return records
}
return append(records, []string{number, text, penalty, escalation})
}
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")
records, err := readCSVFile(filepath.Join(dir, e.Name()))
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
}
}
if strings.ContainsAny(rec[0], ",\"") || !utf8.ValidString(rec[0]) {
log.Printf("rules: skipping malformed row %d in %s: %q", i+1, e.Name(), rec[0])
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("GET /api/rules/languages", requireAuth(handleListRuleLanguages))
mux.HandleFunc("GET /api/rules/numbers", requireAuth(handleListRuleNumbers))
mux.HandleFunc("PUT /api/rules/{lang}/{number}", requireAdmin(handleUpdateRule))
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
}
func handleListRuleLanguages(w http.ResponseWriter, r *http.Request) {
rulesMu.RLock()
defer rulesMu.RUnlock()
out := make([]string, 0, len(rules))
for lang := range rules {
out = append(out, lang)
}
writeJSON(w, http.StatusOK, out)
}
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) {
if err := loadRules(rulesDir()); err != nil {
writeError(w, http.StatusInternalServerError, "load_error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListRuleNumbers returns the union of rule numbers known across every
// loaded language, so the rules-editor UI can show a row even for a rule
// that a given language's file hasn't been given a translation for yet.
func handleListRuleNumbers(w http.ResponseWriter, r *http.Request) {
rulesMu.RLock()
seen := map[string]bool{}
out := make([]string, 0, 64)
for _, langMap := range rules {
for number := range langMap {
if !seen[number] {
seen[number] = true
out = append(out, number)
}
}
}
rulesMu.RUnlock()
writeJSON(w, http.StatusOK, out)
}
func handleUpdateRule(w http.ResponseWriter, r *http.Request) {
lang := r.PathValue("lang")
number := strings.TrimSpace(r.PathValue("number"))
if !langNameRe.MatchString(lang) {
writeError(w, http.StatusBadRequest, "invalid_language")
return
}
path := filepath.Join(rulesDir(), lang+".csv")
if _, err := os.Stat(path); err != nil {
writeError(w, http.StatusNotFound, "unknown_language")
return
}
if number == "" {
writeError(w, http.StatusBadRequest, "invalid_number")
return
}
var req struct {
Text string `json:"text"`
SuggestedPenalty string `json:"suggested_penalty"`
EscalationMode string `json:"escalation_mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
writeError(w, http.StatusBadRequest, "missing_text")
return
}
if req.EscalationMode == "" {
req.EscalationMode = "same"
}
records, err := readCSVFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "read_error")
return
}
if len(records) == 0 {
records = [][]string{{"rule_number", "rule_text", "suggested_penalty", "escalation_mode"}}
}
records = upsertRuleRecord(records, number, req.Text, req.SuggestedPenalty, req.EscalationMode)
if err := writeCSVFile(path, records); err != nil {
writeError(w, http.StatusInternalServerError, "write_error")
return
}
if err := loadRules(rulesDir()); err != nil {
writeError(w, http.StatusInternalServerError, "reload_error")
return
}
rulesMu.RLock()
ru, ok := rules[lang][number]
rulesMu.RUnlock()
if !ok {
writeError(w, http.StatusInternalServerError, "reload_mismatch")
return
}
writeJSON(w, http.StatusOK, ru)
}