From e077703d5d0092f07b82ccd4e5ebe24d3ecaebfb Mon Sep 17 00:00:00 2001 From: Jan Meinl Date: Sat, 5 Sep 2026 13:32:46 +0200 Subject: [PATCH] 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. --- main.go | 4 +- rules.go | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 163 insertions(+), 14 deletions(-) diff --git a/main.go b/main.go index 9f43572..501bcd8 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ type Config struct { var corsOrigins []string var crossSiteCookies bool var backupDir string +var configuredRulesDir string func defaultConfig() *Config { return &Config{ @@ -128,6 +129,7 @@ func main() { corsOrigins = cfg.CORSOrigins crossSiteCookies = cfg.CrossSiteCookies backupDir = cfg.BackupDir + configuredRulesDir = cfg.RulesDir if err := openDB(cfg.DBPath); err != nil { log.Fatalf("db open: %v", err) @@ -285,7 +287,7 @@ func withCORS(next http.Handler) http.Handler { 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") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") reqHeaders := r.Header.Get("Access-Control-Request-Headers") if reqHeaders == "" { reqHeaders = "Content-Type" diff --git a/rules.go b/rules.go index 10b3f30..f40eee3 100644 --- a/rules.go +++ b/rules.go @@ -1,12 +1,17 @@ package main import ( + "bufio" "encoding/csv" + "encoding/json" + "log" "net/http" "os" "path/filepath" + "regexp" "strings" "sync" + "unicode/utf8" ) var ( @@ -14,6 +19,72 @@ var ( 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 { @@ -25,14 +96,7 @@ func loadRules(dir string) error { 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() + records, err := readCSVFile(filepath.Join(dir, e.Name())) if err != nil { continue } @@ -47,6 +111,10 @@ func loadRules(dir string) error { 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]), @@ -91,6 +159,8 @@ func getRules(lang string) []Rule { 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)) } @@ -114,13 +184,90 @@ func handleListRules(w http.ResponseWriter, r *http.Request) { } func handleReloadRules(w http.ResponseWriter, r *http.Request) { - dir := os.Getenv("RULES_DIR") - if dir == "" { - dir = "rules" - } - if err := loadRules(dir); err != nil { + 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) +}