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) }