@@ -0,0 +1,226 @@
|
||||
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)})
|
||||
}
|
||||
Reference in New Issue
Block a user