Implement mcdoc v0.1.0: public documentation server
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>
This commit is contained in:
218
internal/server/fetch.go
Normal file
218
internal/server/fetch.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdoc/internal/cache"
|
||||
"git.wntrmute.dev/mc/mcdoc/internal/gitea"
|
||||
"git.wntrmute.dev/mc/mcdoc/internal/render"
|
||||
)
|
||||
|
||||
// Fetcher coordinates fetching content from Gitea and populating the cache.
|
||||
type Fetcher struct {
|
||||
client *gitea.Client
|
||||
renderer *render.Renderer
|
||||
excludePaths []string
|
||||
excludeRepos map[string]bool
|
||||
concurrency int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// FetcherConfig holds fetcher configuration.
|
||||
type FetcherConfig struct {
|
||||
Client *gitea.Client
|
||||
Renderer *render.Renderer
|
||||
ExcludePaths []string
|
||||
ExcludeRepos []string
|
||||
Concurrency int
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// NewFetcher creates a Fetcher.
|
||||
func NewFetcher(cfg FetcherConfig) *Fetcher {
|
||||
excludeRepos := make(map[string]bool, len(cfg.ExcludeRepos))
|
||||
for _, name := range cfg.ExcludeRepos {
|
||||
excludeRepos[name] = true
|
||||
}
|
||||
|
||||
if cfg.Concurrency < 1 {
|
||||
cfg.Concurrency = 4
|
||||
}
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = slog.Default()
|
||||
}
|
||||
|
||||
return &Fetcher{
|
||||
client: cfg.Client,
|
||||
renderer: cfg.Renderer,
|
||||
excludePaths: cfg.ExcludePaths,
|
||||
excludeRepos: excludeRepos,
|
||||
concurrency: cfg.Concurrency,
|
||||
log: cfg.Log,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchRepo fetches and renders all markdown files for a single repo.
|
||||
func (f *Fetcher) FetchRepo(ctx context.Context, repo gitea.Repo) (*cache.RepoInfo, error) {
|
||||
files, err := f.client.ListMarkdownFiles(ctx, repo.Name, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sha, commitDate, err := f.client.LatestCommitSHA(ctx, repo.Name, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
f.log.Warn("could not get latest commit", "repo", repo.Name, "error", err)
|
||||
}
|
||||
|
||||
var docs []*cache.Document
|
||||
for _, filePath := range files {
|
||||
if f.isExcluded(filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := f.client.FetchFileContent(ctx, repo.Name, repo.DefaultBranch, filePath)
|
||||
if err != nil {
|
||||
f.log.Warn("skip file", "repo", repo.Name, "file", filePath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := f.renderer.Render(content)
|
||||
if err != nil {
|
||||
f.log.Warn("render failed", "repo", repo.Name, "file", filePath, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
urlPath := strings.TrimSuffix(filePath, filepath.Ext(filePath))
|
||||
title := titleFromHeadings(result.Headings)
|
||||
if title == "" {
|
||||
title = titleFromPath(filePath)
|
||||
}
|
||||
|
||||
docs = append(docs, &cache.Document{
|
||||
Repo: repo.Name,
|
||||
FilePath: filePath,
|
||||
URLPath: urlPath,
|
||||
Title: title,
|
||||
HTML: result.HTML,
|
||||
Headings: result.Headings,
|
||||
LastUpdated: commitDate,
|
||||
})
|
||||
}
|
||||
|
||||
return &cache.RepoInfo{
|
||||
Name: repo.Name,
|
||||
Description: repo.Description,
|
||||
Docs: docs,
|
||||
CommitSHA: sha,
|
||||
FetchedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) isExcluded(filePath string) bool {
|
||||
for _, pattern := range f.excludePaths {
|
||||
if strings.Contains(filePath, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func titleFromHeadings(headings []render.Heading) string {
|
||||
for _, h := range headings {
|
||||
if h.Level == 1 {
|
||||
return h.Text
|
||||
}
|
||||
}
|
||||
if len(headings) > 0 {
|
||||
return headings[0].Text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func titleFromPath(filePath string) string {
|
||||
base := filepath.Base(filePath)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
func fetchAllRepos(ctx context.Context, cfg BackgroundConfig) error {
|
||||
repos, err := cfg.Fetcher.client.ListRepos(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, cfg.Fetcher.concurrency)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
|
||||
for _, repo := range repos {
|
||||
if cfg.Fetcher.excludeRepos[repo.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(r gitea.Repo) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
info, err := cfg.Fetcher.FetchRepo(ctx, r)
|
||||
if err != nil {
|
||||
cfg.Log.Warn("fetch repo failed", "repo", r.Name, "error", err)
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if len(info.Docs) > 0 {
|
||||
cfg.Cache.SetRepo(info)
|
||||
}
|
||||
cfg.Log.Info("fetched repo", "repo", r.Name, "docs", len(info.Docs))
|
||||
}(repo)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func pollForChanges(ctx context.Context, cfg BackgroundConfig) error {
|
||||
repos, err := cfg.Fetcher.client.ListRepos(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
if cfg.Fetcher.excludeRepos[repo.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
sha, _, err := cfg.Fetcher.client.LatestCommitSHA(ctx, repo.Name, repo.DefaultBranch)
|
||||
if err != nil {
|
||||
cfg.Log.Warn("poll: could not check commit", "repo", repo.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
cached := cfg.Cache.GetCommitSHA(repo.Name)
|
||||
if sha == cached {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg.Log.Info("repo changed, re-fetching", "repo", repo.Name, "old_sha", cached, "new_sha", sha)
|
||||
info, err := cfg.Fetcher.FetchRepo(ctx, repo)
|
||||
if err != nil {
|
||||
cfg.Log.Warn("poll: re-fetch failed", "repo", repo.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
cfg.Cache.SetRepo(info)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
422
internal/server/server.go
Normal file
422
internal/server/server.go
Normal file
@@ -0,0 +1,422 @@
|
||||
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
|
||||
}
|
||||
325
internal/server/server_test.go
Normal file
325
internal/server/server_test.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/mc/mcdoc/internal/cache"
|
||||
"git.wntrmute.dev/mc/mcdoc/internal/render"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, c *cache.Cache, secret string) *Server {
|
||||
t.Helper()
|
||||
srv, err := New(Config{
|
||||
Cache: c,
|
||||
WebhookSecret: secret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestHealthNotReady(t *testing.T) {
|
||||
c := cache.New()
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReady(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetReady()
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexNotReady(t *testing.T) {
|
||||
c := cache.New()
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexReady(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetRepo(&cache.RepoInfo{Name: "testrepo", Description: "A test"})
|
||||
c.SetReady()
|
||||
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "testrepo") {
|
||||
t.Fatal("expected testrepo in index page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPage(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetRepo(&cache.RepoInfo{
|
||||
Name: "mcr",
|
||||
Docs: []*cache.Document{
|
||||
{Repo: "mcr", URLPath: "README", Title: "README"},
|
||||
},
|
||||
})
|
||||
c.SetReady()
|
||||
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcr/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "README") {
|
||||
t.Fatal("expected README in repo page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoPageNotFound(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetReady()
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nonexistent/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocPage(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetRepo(&cache.RepoInfo{
|
||||
Name: "mcr",
|
||||
Docs: []*cache.Document{
|
||||
{
|
||||
Repo: "mcr",
|
||||
URLPath: "ARCHITECTURE",
|
||||
Title: "Architecture",
|
||||
HTML: "<h1>Architecture</h1><p>content</p>",
|
||||
Headings: []render.Heading{
|
||||
{Level: 1, ID: "architecture", Text: "Architecture"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
c.SetReady()
|
||||
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcr/ARCHITECTURE", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Architecture") {
|
||||
t.Fatal("expected Architecture in doc page")
|
||||
}
|
||||
if !strings.Contains(body, "content") {
|
||||
t.Fatal("expected rendered content in doc page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocPageNotFound(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetRepo(&cache.RepoInfo{Name: "mcr"})
|
||||
c.SetReady()
|
||||
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcr/nonexistent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTMXPartialResponse(t *testing.T) {
|
||||
c := cache.New()
|
||||
c.SetRepo(&cache.RepoInfo{Name: "r", Description: "test"})
|
||||
c.SetReady()
|
||||
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
// Partial should not include the full layout (no <html> tag)
|
||||
if strings.Contains(body, "<html") {
|
||||
t.Fatal("htmx response should not include full layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookValidSignature(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
payload := `{"repository":{"name":"mcr","full_name":"mc/mcr"}}`
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
c := cache.New()
|
||||
c.SetReady()
|
||||
|
||||
webhookCh := make(chan string, 1)
|
||||
srv := newTestServer(t, c, secret)
|
||||
srv.onWebhook = func(repo string) {
|
||||
webhookCh <- repo
|
||||
}
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(payload))
|
||||
req.Header.Set("X-Gitea-Signature", sig)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
select {
|
||||
case repo := <-webhookCh:
|
||||
if repo != "mcr" {
|
||||
t.Fatalf("webhook repo = %q, want mcr", repo)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("webhook callback not called within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookInvalidSignature(t *testing.T) {
|
||||
c := cache.New()
|
||||
srv := newTestServer(t, c, "real-secret")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"repository":{"name":"mcr"}}`))
|
||||
req.Header.Set("X-Gitea-Signature", "bad-signature")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookNoSecret(t *testing.T) {
|
||||
c := cache.New()
|
||||
srv := newTestServer(t, c, "")
|
||||
handler := srv.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRepoName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "valid payload",
|
||||
payload: `{"repository":{"id":1,"name":"mcr","full_name":"mc/mcr"}}`,
|
||||
want: "mcr",
|
||||
},
|
||||
{
|
||||
name: "no repository",
|
||||
payload: `{"action":"push"}`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
payload: `{}`,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractRepoName([]byte(tt.payload))
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractRepoName = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"/foo/bar", "foo/bar"},
|
||||
{"../etc/passwd", "etc/passwd"},
|
||||
{"normal/path", "normal/path"},
|
||||
{"../../bad", "bad"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := sanitizePath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Fatalf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user