769 lines
22 KiB
Go
769 lines
22 KiB
Go
|
|
// 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
|