Single-binary Go server that fetches markdown from Gitea (mc org), renders to HTML with goldmark (GFM, chroma syntax highlighting, heading anchors), and serves a navigable read-only documentation site. Features: - Boot fetch with retry, webhook refresh, 15-minute poll fallback - In-memory cache with atomic per-repo swap - chi router with htmx partial responses for SPA-like navigation - HMAC-SHA256 webhook validation - Responsive CSS, TOC generation, priority doc ordering - $PORT env var support for MCP agent port assignment 33 tests across config, cache, render, and server packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
423 lines
9.9 KiB
Go
423 lines
9.9 KiB
Go
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()
|
|
|
|
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)
|
|
}
|
|
|
|
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":"<value>"
|
|
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()
|
|
log.Info("initial fetch complete")
|
|
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
|
|
}
|