package main import ( "encoding/json" "errors" "io" "io/fs" "net/http" "os" "strconv" "strings" "kb-editor/internal/store" ) type appConfig struct { Mode string `json:"mode"` Title string `json:"title"` Subtitle string `json:"subtitle"` Writable bool `json:"writable"` } type app struct { store *store.Store web fs.FS config appConfig } func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app { cfg := appConfig{Mode: "editor", Title: "Knowledge Base Editor", Subtitle: "JSON · Massenbearbeitung · Docker", Writable: true} if len(configs) > 0 { cfg = configs[0] } return &app{store: s, web: web, config: cfg} } func (a *app) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/health", a.handleHealth) mux.HandleFunc("GET /api/config", a.handleConfig) mux.HandleFunc("GET /api/items", a.handleList) mux.HandleFunc("GET /api/search", a.handleSearch) mux.HandleFunc("GET /api/facets", a.handleFacets) mux.HandleFunc("GET /api/items/{key}", a.handleGet) if a.config.Writable { mux.HandleFunc("PUT /api/items/{key}", a.handlePut) mux.HandleFunc("POST /api/bulk", a.handleBulk) mux.HandleFunc("POST /api/reload", a.handleReload) } else { mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly) mux.HandleFunc("POST /api/bulk", a.handleReadOnly) mux.HandleFunc("POST /api/reload", a.handleReadOnly) } static := http.FileServer(http.FS(a.web)) mux.Handle("GET /", static) return securityHeaders(mux) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") next.ServeHTTP(w, r) }) } func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) { payload := map[string]any{ "ok": true, "count": a.store.Count(), "data_dir": a.store.DataDir(), "mode": a.config.Mode, "writable": a.config.Writable, } if a.config.Writable { payload["backup_dir"] = a.store.BackupDir() } writeJSON(w, http.StatusOK, payload) } func (a *app) handleConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, a.config) } func (a *app) handleList(w http.ResponseWriter, r *http.Request) { q := queryFromURL(r) writeJSON(w, http.StatusOK, a.store.List(q)) } func (a *app) handleSearch(w http.ResponseWriter, r *http.Request) { q := queryFromURL(r) writeJSON(w, http.StatusOK, a.store.Search(q)) } func (a *app) handleFacets(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) writeJSON(w, http.StatusOK, a.store.Facets(limit)) } func queryFromURL(r *http.Request) store.Query { v := r.URL.Query() page, _ := strconv.Atoi(v.Get("page")) pageSize, _ := strconv.Atoi(v.Get("page_size")) return store.Query{ Q: v.Get("q"), AutoReply: v.Get("auto_reply"), Language: v.Get("language"), CommunicationStyle: v.Get("communication_style"), Source: v.Get("source"), Page: page, PageSize: pageSize, } } func (a *app) handleGet(w http.ResponseWriter, r *http.Request) { doc, meta, err := a.store.Get(r.PathValue("key")) if errors.Is(err, os.ErrNotExist) { writeError(w, http.StatusNotFound, "Eintrag nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"document": doc, "meta": meta}) } func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt") } func (a *app) handlePut(w http.ResponseWriter, r *http.Request) { if !mustJSONContentType(w, r) { return } var doc map[string]any if err := decodeJSON(r, &doc); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON: "+err.Error()) return } meta, backup, err := a.store.Save(r.PathValue("key"), doc) if errors.Is(err, os.ErrNotExist) { writeError(w, http.StatusNotFound, "Eintrag nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "meta": meta, "backup": backup}) } type bulkRequest struct { Keys []string `json:"keys"` AllMatching bool `json:"all_matching"` Query store.Query `json:"query"` Patch store.BulkPatch `json:"patch"` DryRun bool `json:"dry_run"` } func (a *app) handleBulk(w http.ResponseWriter, r *http.Request) { if !mustJSONContentType(w, r) { return } var req bulkRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error()) return } keys := req.Keys if req.AllMatching { keys = a.store.MatchingKeys(req.Query) } if len(keys) == 0 { writeError(w, http.StatusBadRequest, "Keine Zieldateien ausgewählt") return } result, err := a.store.ApplyBulk(keys, req.Patch, req.DryRun) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, result) } func (a *app) handleReload(w http.ResponseWriter, r *http.Request) { if !mustJSONContentType(w, r) { return } if err := a.store.Reload(); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "count": a.store.Count()}) } func decodeJSON(r *http.Request, dst any) error { dec := json.NewDecoder(io.LimitReader(r.Body, 8<<20)) dec.UseNumber() if err := dec.Decode(dst); err != nil { return err } var extra any if err := dec.Decode(&extra); err != io.EOF { if err == nil { return errors.New("mehr als ein JSON-Wert im Request") } return err } return nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]any{"error": strings.TrimSpace(message)}) }