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.
This commit is contained in:
2026-09-05 13:32:46 +02:00
parent 24a4eee4a6
commit e077703d5d
2 changed files with 163 additions and 14 deletions
+3 -1
View File
@@ -29,6 +29,7 @@ type Config struct {
var corsOrigins []string var corsOrigins []string
var crossSiteCookies bool var crossSiteCookies bool
var backupDir string var backupDir string
var configuredRulesDir string
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
@@ -128,6 +129,7 @@ func main() {
corsOrigins = cfg.CORSOrigins corsOrigins = cfg.CORSOrigins
crossSiteCookies = cfg.CrossSiteCookies crossSiteCookies = cfg.CrossSiteCookies
backupDir = cfg.BackupDir backupDir = cfg.BackupDir
configuredRulesDir = cfg.RulesDir
if err := openDB(cfg.DBPath); err != nil { if err := openDB(cfg.DBPath); err != nil {
log.Fatalf("db open: %v", err) 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("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true") 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") reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if reqHeaders == "" { if reqHeaders == "" {
reqHeaders = "Content-Type" reqHeaders = "Content-Type"
+160 -13
View File
@@ -1,12 +1,17 @@
package main package main
import ( import (
"bufio"
"encoding/csv" "encoding/csv"
"encoding/json"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"unicode/utf8"
) )
var ( var (
@@ -14,6 +19,72 @@ var (
rules = map[string]map[string]Rule{} 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 { func loadRules(dir string) error {
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
@@ -25,14 +96,7 @@ func loadRules(dir string) error {
continue continue
} }
lang := strings.TrimSuffix(e.Name(), ".csv") lang := strings.TrimSuffix(e.Name(), ".csv")
f, err := os.Open(filepath.Join(dir, e.Name())) records, err := readCSVFile(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 { if err != nil {
continue continue
} }
@@ -47,6 +111,10 @@ func loadRules(dir string) error {
continue 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{ ru := Rule{
Number: strings.TrimSpace(rec[0]), Number: strings.TrimSpace(rec[0]),
Text: strings.TrimSpace(rec[1]), Text: strings.TrimSpace(rec[1]),
@@ -91,6 +159,8 @@ func getRules(lang string) []Rule {
func registerRuleRoutes(mux *http.ServeMux) { func registerRuleRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules)) mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
mux.HandleFunc("GET /api/rules/languages", requireAuth(handleListRuleLanguages)) 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)) 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) { func handleReloadRules(w http.ResponseWriter, r *http.Request) {
dir := os.Getenv("RULES_DIR") if err := loadRules(rulesDir()); err != nil {
if dir == "" {
dir = "rules"
}
if err := loadRules(dir); err != nil {
writeError(w, http.StatusInternalServerError, "load_error") writeError(w, http.StatusInternalServerError, "load_error")
return return
} }
w.WriteHeader(http.StatusNoContent) 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)
}