initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

768
backend/router/router.go Normal file
View file

@ -0,0 +1,768 @@
// Package router wires the go-doc REST API onto net/http.
//
// It exposes flat-file CRUD for Markdown documents, audience-badged comment
// tracking, and -- when built via `make build` -- serves the compiled React
// bundle from an embedded filesystem so the Go binary is fully self-contained.
// Routes use the standard library's method-and-wildcard patterns, which keeps
// the backend dependency-free.
package router
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path"
"runtime/debug"
"strconv"
"strings"
"time"
"go-doc/auth"
"go-doc/go-loose/client"
"go-doc/storage"
)
// Default payload ceilings, applied with http.MaxBytesReader before any parsing.
const (
DefaultMaxDocBytes = 5 << 20 // 5 MiB of Markdown
DefaultMaxCommentBytes = 256 << 10 // 256 KiB per comment
DefaultAllowedOrigin = "*"
)
// Config tunes the HTTP layer. Zero values fall back to the defaults above.
type Config struct {
Engine *storage.Engine
Static fs.FS
AllowedOrigins []string
MaxDocBytes int64
MaxCommentByte int64
Logger *slog.Logger
// DevFallback is returned when Static is nil so a developer hitting the API
// port directly gets an actionable hint instead of a bare 404.
DevFallback string
// BrowserAuth enables go-loose OAuth authentication for the web UI.
BrowserAuth *client.BrowserAuth
}
func (c Config) withDefaults() Config {
if c.MaxDocBytes <= 0 {
c.MaxDocBytes = DefaultMaxDocBytes
}
if c.MaxCommentByte <= 0 {
c.MaxCommentByte = DefaultMaxCommentBytes
}
if len(c.AllowedOrigins) == 0 {
c.AllowedOrigins = []string{DefaultAllowedOrigin}
}
if c.Logger == nil {
c.Logger = slog.Default()
}
return c
}
// New builds the fully decorated handler around a storage engine.
func New(engine *storage.Engine) (http.Handler, error) {
return NewWithConfig(Config{Engine: engine})
}
// NewWithConfig builds the handler stack from an explicit configuration.
func NewWithConfig(cfg Config) (http.Handler, error) {
cfg = cfg.withDefaults()
if cfg.Engine == nil {
engine, err := storage.Default()
if err != nil {
return nil, fmt.Errorf("router: resolve storage engine: %w", err)
}
cfg.Engine = engine
}
h := &handler{engine: cfg.Engine, cfg: cfg}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", h.health)
mux.HandleFunc("GET /api/roles", h.roles)
mux.HandleFunc("GET /api/docs", h.listDocs)
mux.HandleFunc("GET /api/docs/{slug}", h.getDoc)
mux.HandleFunc("HEAD /api/docs/{slug}", h.getDoc)
mux.HandleFunc("POST /api/docs/{slug}", h.saveDoc)
mux.HandleFunc("PUT /api/docs/{slug}", h.saveDoc)
mux.HandleFunc("DELETE /api/docs/{slug}", h.deleteDoc)
mux.HandleFunc("GET /api/docs/{slug}/comments", h.listComments)
mux.HandleFunc("POST /api/docs/{slug}/comments", h.createComment)
mux.HandleFunc("DELETE /api/docs/{slug}/comments/{id}", h.deleteComment)
mux.HandleFunc("GET /api/docs/{slug}/stats", h.docStats)
mux.HandleFunc("GET /api/stats", h.globalStats)
// Operations endpoints
mux.HandleFunc("GET /api/ops/scripts", h.downloadScripts)
mux.HandleFunc("GET /api/ops/docs", h.getOpsDoc)
// Auth endpoints
mux.HandleFunc("GET /auth/login", h.login)
mux.HandleFunc("GET /auth/callback", h.callback)
mux.HandleFunc("POST /auth/logout", h.logout)
mux.HandleFunc("GET /api/auth/profile", h.profile)
mux.Handle("/", auth.BrowserAuthMiddleware(cfg.BrowserAuth)(h.frontend()))
var handler http.Handler = mux
handler = corsMiddleware(cfg.AllowedOrigins)(handler)
handler = loggingMiddleware(cfg.Logger)(handler)
handler = recoverMiddleware(cfg.Logger)(handler)
return handler, nil
}
type handler struct {
engine *storage.Engine
cfg Config
}
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(payload); err != nil {
slog.Error("router: encode response", "error", err)
}
}
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
} `json:"error"`
Status int `json:"status"`
}
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
body.Error.RequestID = requestIDFrom(r.Context())
body.Status = status
writeJSON(w, status, body)
}
// writeStoreError maps engine sentinels onto HTTP status codes.
func (h *handler) writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, storage.ErrNotFound):
writeError(w, r, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, storage.ErrInvalidSlug):
writeError(w, r, http.StatusBadRequest, "invalid_slug", err.Error())
case errors.Is(err, storage.ErrInvalidRole):
writeError(w, r, http.StatusBadRequest, "invalid_role", err.Error())
case errors.Is(err, storage.ErrInvalidComment):
writeError(w, r, http.StatusBadRequest, "invalid_comment", err.Error())
default:
h.cfg.Logger.Error("storage failure", "error", err, "path", r.URL.Path)
writeError(w, r, http.StatusInternalServerError, "internal_error", "storage operation failed")
}
}
// ---------------------------------------------------------------------------
// API: documents
// ---------------------------------------------------------------------------
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "go-doc",
"data_root": h.engine.Root(),
"time": time.Now().UTC(),
})
}
func (h *handler) roles(w http.ResponseWriter, r *http.Request) {
roles := make([]map[string]string, 0, 3)
for _, role := range storage.Roles() {
roles = append(roles, map[string]string{
"role": string(role),
"badge": role.Badge(),
"label": role.Label(),
"hex": role.Hex(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
func (h *handler) listDocs(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
detailed := r.URL.Query().Get("counts") == "1" || r.URL.Query().Get("counts") == "true"
var (
docs []storage.DocMeta
err error
)
if detailed {
docs, err = h.engine.ListDocsDetailed()
} else {
docs, err = h.engine.Search(query)
}
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"docs": docs,
"count": len(docs),
"query": query,
"data_root": h.engine.Root(),
})
}
func (h *handler) getDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
content, err := h.engine.ReadDoc(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
if wantsJSON(r) {
meta, metaErr := h.engine.Stat(slug)
if metaErr != nil {
h.writeStoreError(w, r, metaErr)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"content": content,
"size": meta.Size,
"modified": meta.Modified,
"comments": meta.Comments,
})
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, content); err != nil {
h.cfg.Logger.Error("write markdown response", "error", err, "slug", slug)
}
}
// saveRequest is the JSON body accepted by POST /api/docs/{slug}. Raw
// text/markdown bodies are accepted too, which is what the Markdown editor tab
// in the UI sends.
type saveRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Slug string `json:"slug"`
}
func (h *handler) saveDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxDocBytes)
title := strings.TrimSpace(r.URL.Query().Get("title"))
var content string
if isJSONRequest(r) {
var payload saveRequest
if err := json.NewDecoder(body).Decode(&payload); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with a content field: "+err.Error())
return
}
content = payload.Content
if t := strings.TrimSpace(payload.Title); t != "" {
title = t
}
} else {
raw, err := io.ReadAll(body)
if err != nil {
writeError(w, r, http.StatusBadRequest, "body_too_large", fmt.Sprintf("document exceeds %d bytes", h.cfg.MaxDocBytes))
return
}
content = string(raw)
}
existed, err := h.engine.DocExists(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
normalized, err := h.engine.SaveDoc(slug, content)
if err != nil {
h.writeStoreError(w, r, err)
return
}
meta, err := h.engine.Stat(normalized)
if err != nil {
h.writeStoreError(w, r, err)
return
}
if title != "" {
meta.Title = title
}
status := http.StatusOK
if !existed {
status = http.StatusCreated
}
writeJSON(w, status, map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"size": meta.Size,
"modified": meta.Modified,
"created": !existed,
})
}
func (h *handler) deleteDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := h.engine.DeleteDoc(slug); err != nil {
h.writeStoreError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) listComments(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
comments, err := h.engine.ListComments(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
// Filter comments by tenant if user is authenticated
if user, ok := auth.UserFromContext(r.Context()); ok {
filtered := make([]storage.Comment, 0, len(comments))
for _, c := range comments {
// Admin can see all comments, otherwise only show comments from same tenant
if user.IsAdmin() || c.TenantID == user.TenantID {
filtered = append(filtered, c)
}
}
comments = filtered
}
writeJSON(w, http.StatusOK, map[string]any{
"slug": storage.Slugify(slug),
"comments": comments,
"count": len(comments),
})
}
func (h *handler) createComment(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxCommentByte)
var input storage.CommentInput
if err := json.NewDecoder(body).Decode(&input); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with author, role and content: "+err.Error())
return
}
// Attach tenant/user info from auth context if available
if user, ok := auth.UserFromContext(r.Context()); ok {
input.TenantID = user.TenantID
input.TenantSlug = user.TenantSlug
input.UserID = user.ID
if input.Author == "" {
input.Author = user.DisplayName
}
}
comment, err := h.engine.AppendComment(slug, input)
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"comment": comment,
"badge": comment.Badge,
})
}
func (h *handler) deleteComment(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
removed, err := h.engine.DeleteComment(slug, r.PathValue("id"))
if err != nil {
h.writeStoreError(w, r, err)
return
}
if !removed {
writeError(w, r, http.StatusNotFound, "not_found", "comment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) docStats(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
stats, err := h.engine.Stats(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusOK, stats)
}
func (h *handler) globalStats(w http.ResponseWriter, r *http.Request) {
docs, err := h.engine.ListDocs()
if err != nil {
h.writeStoreError(w, r, err)
return
}
byRole := map[string]int{}
totalComments := 0
for _, doc := range docs {
stats, err := h.engine.Stats(doc.Slug)
if err != nil {
continue
}
totalComments += stats.Total
for role, n := range stats.ByRole {
byRole[role] += n
}
}
writeJSON(w, http.StatusOK, map[string]any{
"docs": len(docs),
"comments": totalComments,
"by_role": byRole,
})
}
// downloadScripts serves the operations scripts zip file.
func (h *handler) downloadScripts(w http.ResponseWriter, r *http.Request) {
zipPath := path.Join(h.engine.DocsDir(), "ops", "scripts.zip")
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
writeError(w, r, http.StatusNotFound, "not_found", "scripts archive not found")
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=\"ops-scripts.zip\"")
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, zipPath)
}
// getOpsDoc serves the operations documentation.
func (h *handler) getOpsDoc(w http.ResponseWriter, r *http.Request) {
docPath := path.Join(h.engine.DocsDir(), "ops", "DEPLOYMENT.md")
content, err := os.ReadFile(docPath)
if err != nil {
if os.IsNotExist(err) {
writeError(w, r, http.StatusNotFound, "not_found", "documentation not found")
return
}
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to read documentation")
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
// login redirects to go-loose for authentication.
func (h *handler) login(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.LoginHandler(w, r)
}
// callback handles the OAuth callback from go-loose.
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.CallbackHandler(w, r)
}
// logout clears the session and redirects to go-loose logout.
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.LogoutHandler(w, r)
}
// profile returns the current user's profile.
func (h *handler) profile(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, r, http.StatusUnauthorized, "unauthorized", "not authenticated")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
"avatar_url": user.AvatarURL,
"tenant_id": user.TenantID,
"tenant_slug": user.TenantSlug,
"application_id": user.ApplicationID,
"application": user.Application,
"role": user.Role,
"permissions": user.Permissions,
"memberships": []any{}, // TODO: fetch from go-loose if needed
})
}
// ---------------------------------------------------------------------------
// Frontend
// ---------------------------------------------------------------------------
func (h *handler) frontend() http.Handler {
if h.cfg.Static == nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
return
}
message := h.cfg.DevFallback
if message == "" {
message = "go-doc backend is running. Start the Vite dev server with `make frontend-run`, or run `make build` to embed the production bundle."
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "<!doctype html><meta charset=\"utf-8\"><title>go-doc API</title><body style=\"font-family:ui-monospace,monospace;background:#0b0f14;color:#cbd5e1;padding:3rem\"><h1>go-doc API</h1><p>%s</p><p>Endpoints: <code>/api/health</code>, <code>/api/docs</code>, <code>/api/docs/{slug}</code>, <code>/api/docs/{slug}/comments</code>.</p></body>", message)
})
}
return spaHandler(h.cfg.Static)
}
// spaHandler serves the embedded React bundle and falls back to index.html for
// client-side routes such as /docs/core-service-api.
func spaHandler(static fs.FS) http.Handler {
files := http.FileServerFS(static)
serveIndex := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
data, err := fs.ReadFile(static, "index.html")
if err != nil {
http.Error(w, "frontend bundle missing: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
w.Write(data)
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" || name == "." {
serveIndex(w, r)
return
}
if !fs.ValidPath(name) {
writeError(w, r, http.StatusBadRequest, "invalid_path", "invalid request path")
return
}
f, err := static.Open(name)
if err != nil {
serveIndex(w, r)
return
}
info, statErr := f.Stat()
f.Close()
if statErr != nil || info.IsDir() {
serveIndex(w, r)
return
}
// Hashed build artefacts are immutable; everything else must revalidate.
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
func corsMiddleware(allowed []string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, o := range allowed {
if trimmed := strings.TrimSpace(o); trimmed != "" {
set[strings.ToLower(trimmed)] = struct{}{}
}
}
// A literal "*" entry means "reflect any origin", which is the default
// configuration. Credentials are never allowed, so the wildcard stays safe.
_, allowAll := set["*"]
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
switch {
case origin == "":
w.Header().Set("Access-Control-Allow-Origin", DefaultAllowedOrigin)
case allowAll:
w.Header().Set("Access-Control-Allow-Origin", "*")
default:
normalized := strings.TrimSpace(strings.ToLower(origin))
if _, ok := set[normalized]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set(
"Access-Control-Allow-Headers",
"Origin, Content-Type, Content-Length, Accept, Authorization, X-Requested-With, X-Request-Id",
)
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id, Content-Length, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
if s.status == 0 {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
status := rec.status
if status == 0 {
status = http.StatusOK
}
level := slog.LevelInfo
if status >= 500 {
level = slog.LevelError
} else if status >= 400 {
level = slog.LevelWarn
}
logger.Log(r.Context(), level, "http",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic recovered",
"error", fmt.Sprint(rec),
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
writeError(w, r, http.StatusInternalServerError, "internal_error", "unexpected server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
func isJSONRequest(r *http.Request) bool {
ct := r.Header.Get("Content-Type")
if ct == "" {
return false
}
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.ToLower(strings.TrimSpace(ct))
return ct == "application/json" || strings.HasSuffix(ct, "+json")
}
func wantsJSON(r *http.Request) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) {
case "json":
return true
}
accept := r.Header.Get("Accept")
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/markdown")
}
func requestIDFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
const requestIDKey = contextKey("godoc-request-id")
type contextKey string