package server import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "html/template" "io" "io/fs" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "git.wntrmute.dev/mc/mcdoc/internal/cache" "git.wntrmute.dev/mc/mcdoc/internal/render" "git.wntrmute.dev/mc/mcdoc/web" ) // Server is the mcdoc HTTP server. type Server struct { cache *cache.Cache pages map[string]*template.Template loadingTmpl *template.Template webhookSecret string onWebhook func(repo string) log *slog.Logger } // Config holds server configuration. type Config struct { Cache *cache.Cache WebhookSecret string OnWebhook func(repo string) Log *slog.Logger } // New creates a server with its routes. func New(cfg Config) (*Server, error) { tmplFS, err := fs.Sub(web.Content, "templates") if err != nil { return nil, fmt.Errorf("open templates: %w", err) } funcMap := template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) // #nosec G203 -- content is rendered from markdown by our own renderer }, } layoutTmpl, err := template.New("layout.html").Funcs(funcMap).ParseFS(tmplFS, "layout.html") if err != nil { return nil, fmt.Errorf("parse layout: %w", err) } pageNames := []string{"index.html", "repo.html", "doc.html", "error.html"} pages := make(map[string]*template.Template, len(pageNames)) for _, name := range pageNames { clone, err := layoutTmpl.Clone() if err != nil { return nil, fmt.Errorf("clone layout for %s: %w", name, err) } _, err = clone.ParseFS(tmplFS, name) if err != nil { return nil, fmt.Errorf("parse %s: %w", name, err) } pages[name] = clone } loadingTmpl, err := template.New("loading.html").ParseFS(tmplFS, "loading.html") if err != nil { return nil, fmt.Errorf("parse loading template: %w", err) } if cfg.Log == nil { cfg.Log = slog.Default() } return &Server{ cache: cfg.Cache, pages: pages, loadingTmpl: loadingTmpl, webhookSecret: cfg.WebhookSecret, onWebhook: cfg.OnWebhook, log: cfg.Log, }, nil } // Handler returns the chi router with all routes mounted. func (s *Server) Handler() http.Handler { r := chi.NewRouter() r.Use(s.requestLogger) staticFS, err := fs.Sub(web.Content, "static") if err != nil { s.log.Error("failed to open static fs", "error", err) } else { r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) } r.Get("/health", s.handleHealth) r.Post("/webhook", s.handleWebhook) r.Get("/", s.handleIndex) r.Get("/{repo}/", s.handleRepo) r.Get("/{repo}/*", s.handleDoc) return r } // Breadcrumb is a navigation element. type Breadcrumb struct { Label string URL string } // SidebarItem is a sidebar navigation entry. type SidebarItem struct { Label string URL string Active bool } type pageData struct { Title string Breadcrumbs []Breadcrumb Sidebar []SidebarItem LastUpdated string // Index page Repos []*cache.RepoInfo // Repo page RepoName string RepoDescription string Docs []*cache.Document // Doc page Content template.HTML TOC []render.Heading // Error page Code int Message string } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if !s.cache.IsReady() { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte("loading")) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if !s.cache.IsReady() { s.renderLoading(w) return } data := pageData{ Repos: s.cache.ListRepos(), } s.render(w, r, "index.html", data, http.StatusOK) } func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) { if !s.cache.IsReady() { s.renderLoading(w) return } repoName := chi.URLParam(r, "repo") repoName = sanitizePath(repoName) repo, ok := s.cache.GetRepo(repoName) if !ok { s.renderError(w, r, http.StatusNotFound, "Repository not found.") return } data := pageData{ Title: repo.Name, RepoName: repo.Name, RepoDescription: repo.Description, Docs: repo.Docs, Breadcrumbs: []Breadcrumb{ {Label: repo.Name, URL: "/" + repo.Name + "/"}, }, } s.render(w, r, "repo.html", data, http.StatusOK) } func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) { if !s.cache.IsReady() { s.renderLoading(w) return } repoName := chi.URLParam(r, "repo") repoName = sanitizePath(repoName) docPath := chi.URLParam(r, "*") docPath = sanitizePath(docPath) doc, ok := s.cache.GetDocument(repoName, docPath) if !ok { s.renderError(w, r, http.StatusNotFound, "Document not found.") return } repo, _ := s.cache.GetRepo(repoName) var sidebar []SidebarItem if repo != nil { for _, d := range repo.Docs { sidebar = append(sidebar, SidebarItem{ Label: d.Title, URL: "/" + repoName + "/" + d.URLPath, Active: d.URLPath == docPath, }) } } lastUpdated := "" if !doc.LastUpdated.IsZero() { lastUpdated = doc.LastUpdated.Format("2006-01-02 15:04 UTC") } data := pageData{ Title: doc.Title + " — " + repoName, Content: template.HTML(doc.HTML), // #nosec G203 -- rendered by our goldmark pipeline TOC: doc.Headings, Sidebar: sidebar, Breadcrumbs: []Breadcrumb{ {Label: repoName, URL: "/" + repoName + "/"}, {Label: doc.Title, URL: "/" + repoName + "/" + doc.URLPath}, }, LastUpdated: lastUpdated, } s.render(w, r, "doc.html", data, http.StatusOK) } func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { if s.webhookSecret == "" { http.Error(w, "webhook not configured", http.StatusInternalServerError) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } sig := r.Header.Get("X-Gitea-Signature") if !verifyHMAC(body, sig, s.webhookSecret) { http.Error(w, "invalid signature", http.StatusForbidden) return } repoName := extractRepoName(body) if repoName == "" { http.Error(w, "cannot determine repo", http.StatusBadRequest) return } s.log.Info("webhook received", "repo", repoName) if s.onWebhook != nil { go s.onWebhook(repoName) } w.WriteHeader(http.StatusNoContent) } func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData, status int) { if r.Header.Get("HX-Request") == "true" { s.renderPartial(w, name, data, status) return } tmpl, ok := s.pages[name] if !ok { s.log.Error("page template not found", "template", name) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil { s.log.Error("render template", "template", name, "error", err) } } func (s *Server) renderPartial(w http.ResponseWriter, name string, data pageData, status int) { tmpl, ok := s.pages[name] if !ok { s.log.Error("page template not found for partial", "template", name) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := tmpl.ExecuteTemplate(w, "content", data); err != nil { s.log.Error("render partial", "template", name, "error", err) } } func (s *Server) renderLoading(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) if err := s.loadingTmpl.Execute(w, nil); err != nil { s.log.Error("render loading", "error", err) } } func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) { data := pageData{ Title: http.StatusText(code), Code: code, Message: message, } s.render(w, r, "error.html", data, code) } type statusWriter struct { http.ResponseWriter code int } func (w *statusWriter) WriteHeader(code int) { w.code = code w.ResponseWriter.WriteHeader(code) } func (s *Server) requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, code: http.StatusOK} next.ServeHTTP(sw, r) duration := time.Since(start) // Skip static assets and health checks at info level if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/health" { s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.code, "duration", duration, ) return } s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", sw.code, "duration", duration, ) }) } func verifyHMAC(body []byte, signature, secret string) bool { if signature == "" { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } // extractRepoName pulls the repo name from a Gitea webhook JSON payload. // We do minimal parsing to avoid importing encoding/json for a single field. func extractRepoName(body []byte) string { // Look for "repository":{"...", "name":"" s := string(body) idx := strings.Index(s, `"repository"`) if idx < 0 { return "" } sub := s[idx:] nameIdx := strings.Index(sub, `"name":"`) if nameIdx < 0 { return "" } start := nameIdx + len(`"name":"`) end := strings.Index(sub[start:], `"`) if end < 0 { return "" } return sanitizePath(sub[start : start+end]) } // sanitizePath removes path traversal components. func sanitizePath(p string) string { // Remove all .. segments before cleaning to prevent traversal. segments := strings.Split(p, "/") var clean []string for _, seg := range segments { if seg == ".." || seg == "." || seg == "" { continue } clean = append(clean, seg) } return strings.Join(clean, "/") } // StartBackgroundFetch coordinates the initial fetch and periodic polling. func StartBackgroundFetch(ctx context.Context, cfg BackgroundConfig) { log := cfg.Log if log == nil { log = slog.Default() } // Initial fetch with retries for { if err := fetchAllRepos(ctx, cfg); err != nil { log.Error("initial fetch failed, retrying in 30s", "error", err) select { case <-ctx.Done(): return case <-time.After(30 * time.Second): continue } } cfg.Cache.SetReady() repos := cfg.Cache.ListRepos() totalDocs := 0 for _, r := range repos { totalDocs += len(r.Docs) } log.Info("initial fetch complete", "repos", len(repos), "docs", totalDocs) break } // Poll loop ticker := time.NewTicker(cfg.PollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := pollForChanges(ctx, cfg); err != nil { log.Warn("poll failed", "error", err) } } } } // BackgroundConfig holds configuration for background fetching. type BackgroundConfig struct { Cache *cache.Cache Fetcher *Fetcher PollInterval time.Duration Log *slog.Logger }