initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
768
backend/router/router.go
Normal file
768
backend/router/router.go
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue