package main import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "os" "path/filepath" "strings" "sync" "time" ) // Issue board write side — port of main.py's POST/PATCH/DELETE /issues. Issues // live in static/openissues.json (the same flat file the read side uses); writes // are serialized by issuesMu. Needs the file mounted read-write in cutover. var issuesMu sync.Mutex func (s *Server) issuesPath() string { return filepath.Join(s.staticDir, "openissues.json") } func (s *Server) loadIssuesRW() []map[string]any { b, err := os.ReadFile(s.issuesPath()) if err != nil { return []map[string]any{} } var v []map[string]any if json.Unmarshal(b, &v) != nil { return []map[string]any{} } return v } func (s *Server) saveIssues(issues []map[string]any) error { b, _ := json.MarshalIndent(issues, "", " ") return os.WriteFile(s.issuesPath(), b, 0o644) } func issueAuthor(r *http.Request) string { if u := currentUser(r); u != nil { return u.Username } return "Anonymous" } func nowISO() string { return time.Now().UTC().Format("2006-01-02T15:04:05.999999") } func randHex8() string { b := make([]byte, 4) _, _ = rand.Read(b) return hex.EncodeToString(b) } // pyHTMLEscape matches Python's html.escape(s, quote=True). func pyHTMLEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "'", "'") return s } // POST /issues func (s *Server) handleAddIssue(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) title := pyHTMLEscape(strings.TrimSpace(toStr(body["title"]))) if title == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title is required"}) return } category := strings.TrimSpace(toStr(body["category"])) if category == "" { category = "other" } newIssue := map[string]any{ "id": randHex8(), "title": title, "description": pyHTMLEscape(strings.TrimSpace(toStr(body["description"]))), "category": pyHTMLEscape(category), "author": issueAuthor(r), "created": nowISO(), "resolved": false, "comments": []any{}, } issuesMu.Lock() defer issuesMu.Unlock() issues := append([]map[string]any{newIssue}, s.loadIssuesRW()...) if err := s.saveIssues(issues); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"}) return } writeJSON(w, http.StatusOK, newIssue) } // PATCH /issues/{issue_id} func (s *Server) handleUpdateIssue(w http.ResponseWriter, r *http.Request) { id := r.PathValue("issue_id") var update map[string]any _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&update) issuesMu.Lock() defer issuesMu.Unlock() issues := s.loadIssuesRW() var found map[string]any for _, i := range issues { if toStr(i["id"]) == id { if v, ok := update["resolved"]; ok { b, _ := v.(bool) i["resolved"] = b } if v, ok := update["title"]; ok { t := pyHTMLEscape(strings.TrimSpace(toStr(v))) if t == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Title cannot be empty"}) return } i["title"] = t } if v, ok := update["description"]; ok { i["description"] = pyHTMLEscape(strings.TrimSpace(toStr(v))) } if v, ok := update["category"]; ok { i["category"] = pyHTMLEscape(toStr(v)) } found = i break } } if found == nil { writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"}) return } if err := s.saveIssues(issues); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"}) return } writeJSON(w, http.StatusOK, found) } // POST /issues/{issue_id}/comments func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) { id := r.PathValue("issue_id") var body map[string]any _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body) issuesMu.Lock() defer issuesMu.Unlock() issues := s.loadIssuesRW() var found map[string]any for _, i := range issues { if toStr(i["id"]) == id { found = i break } } if found == nil { writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Issue not found"}) return } text := pyHTMLEscape(strings.TrimSpace(toStr(body["text"]))) if text == "" { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Comment text is required"}) return } comment := map[string]any{"id": randHex8(), "author": issueAuthor(r), "text": text, "created": nowISO()} comments, _ := found["comments"].([]any) found["comments"] = append(comments, comment) if err := s.saveIssues(issues); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"}) return } writeJSON(w, http.StatusOK, comment) } // DELETE /issues/{issue_id} func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) { id := r.PathValue("issue_id") issuesMu.Lock() defer issuesMu.Unlock() kept := []map[string]any{} for _, i := range s.loadIssuesRW() { if toStr(i["id"]) != id { kept = append(kept, i) } } if err := s.saveIssues(kept); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "save failed"}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) }