// 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, "go-doc API

go-doc API

%s

Endpoints: /api/health, /api/docs, /api/docs/{slug}, /api/docs/{slug}/comments.

", 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