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:
2026-03-27 13:04:15 -07:00
parent 0578dbcb02
commit 28afaa2c56
31 changed files with 2870 additions and 1 deletions

159
internal/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,159 @@
package cache
import (
"sort"
"strings"
"sync"
"time"
"git.wntrmute.dev/mc/mcdoc/internal/render"
)
// Document represents a single rendered markdown document.
type Document struct {
Repo string
FilePath string // original path with .md extension
URLPath string // path without .md extension
Title string // first heading or filename
HTML string
Headings []render.Heading
LastUpdated time.Time
}
// RepoInfo holds metadata about a repository and its documents.
type RepoInfo struct {
Name string
Description string
Docs []*Document
CommitSHA string
FetchedAt time.Time
}
// Cache stores rendered documents in memory with atomic per-repo swaps.
type Cache struct {
mu sync.RWMutex
repos map[string]*RepoInfo
ready bool
}
// New creates an empty cache.
func New() *Cache {
return &Cache{
repos: make(map[string]*RepoInfo),
}
}
// SetRepo atomically replaces all documents for a repository.
func (c *Cache) SetRepo(info *RepoInfo) {
sortDocs(info.Docs)
c.mu.Lock()
c.repos[info.Name] = info
c.mu.Unlock()
}
// RemoveRepo removes a repository from the cache.
func (c *Cache) RemoveRepo(name string) {
c.mu.Lock()
delete(c.repos, name)
c.mu.Unlock()
}
// SetReady marks the cache as ready to serve requests.
func (c *Cache) SetReady() {
c.mu.Lock()
c.ready = true
c.mu.Unlock()
}
// IsReady returns whether the initial fetch has completed.
func (c *Cache) IsReady() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ready
}
// ListRepos returns all cached repos, sorted by name.
func (c *Cache) ListRepos() []*RepoInfo {
c.mu.RLock()
defer c.mu.RUnlock()
repos := make([]*RepoInfo, 0, len(c.repos))
for _, r := range c.repos {
repos = append(repos, r)
}
sort.Slice(repos, func(i, j int) bool {
return repos[i].Name < repos[j].Name
})
return repos
}
// GetRepo returns info for a specific repository.
func (c *Cache) GetRepo(name string) (*RepoInfo, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[name]
return r, ok
}
// GetDocument returns a document by repo name and URL path.
func (c *Cache) GetDocument(repo, urlPath string) (*Document, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[repo]
if !ok {
return nil, false
}
for _, doc := range r.Docs {
if doc.URLPath == urlPath {
return doc, true
}
}
return nil, false
}
// GetCommitSHA returns the cached commit SHA for a repo.
func (c *Cache) GetCommitSHA(repo string) string {
c.mu.RLock()
defer c.mu.RUnlock()
r, ok := c.repos[repo]
if !ok {
return ""
}
return r.CommitSHA
}
// docPriority defines the sort order for well-known filenames.
var docPriority = map[string]int{
"README": 0,
"ARCHITECTURE": 1,
"RUNBOOK": 2,
"CLAUDE": 3,
}
func sortDocs(docs []*Document) {
sort.Slice(docs, func(i, j int) bool {
pi, oki := docPriority[baseNameUpper(docs[i].URLPath)]
pj, okj := docPriority[baseNameUpper(docs[j].URLPath)]
switch {
case oki && okj:
return pi < pj
case oki:
return true
case okj:
return false
default:
return strings.ToLower(docs[i].URLPath) < strings.ToLower(docs[j].URLPath)
}
})
}
func baseNameUpper(urlPath string) string {
parts := strings.Split(urlPath, "/")
base := parts[len(parts)-1]
return strings.ToUpper(base)
}

165
internal/cache/cache_test.go vendored Normal file
View File

@@ -0,0 +1,165 @@
package cache
import (
"testing"
"time"
"git.wntrmute.dev/mc/mcdoc/internal/render"
)
func TestSetAndGetRepo(t *testing.T) {
c := New()
info := &RepoInfo{
Name: "testrepo",
Description: "A test repo",
CommitSHA: "abc123",
FetchedAt: time.Now(),
Docs: []*Document{
{Repo: "testrepo", URLPath: "README", Title: "README", HTML: "<p>hello</p>"},
{Repo: "testrepo", URLPath: "ARCHITECTURE", Title: "Architecture", HTML: "<p>arch</p>"},
},
}
c.SetRepo(info)
got, ok := c.GetRepo("testrepo")
if !ok {
t.Fatal("expected repo to exist")
}
if got.Name != "testrepo" {
t.Fatalf("got name %q, want %q", got.Name, "testrepo")
}
if len(got.Docs) != 2 {
t.Fatalf("got %d docs, want 2", len(got.Docs))
}
}
func TestGetDocument(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{
{Repo: "r", URLPath: "foo/bar", Title: "Bar", HTML: "<p>bar</p>"},
},
})
doc, ok := c.GetDocument("r", "foo/bar")
if !ok {
t.Fatal("expected document to exist")
}
if doc.Title != "Bar" {
t.Fatalf("got title %q, want %q", doc.Title, "Bar")
}
_, ok = c.GetDocument("r", "nonexistent")
if ok {
t.Fatal("expected document to not exist")
}
_, ok = c.GetDocument("nonexistent", "foo/bar")
if ok {
t.Fatal("expected repo to not exist")
}
}
func TestListReposSorted(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{Name: "mcr"})
c.SetRepo(&RepoInfo{Name: "abc"})
c.SetRepo(&RepoInfo{Name: "mcp"})
repos := c.ListRepos()
if len(repos) != 3 {
t.Fatalf("got %d repos, want 3", len(repos))
}
if repos[0].Name != "abc" || repos[1].Name != "mcp" || repos[2].Name != "mcr" {
t.Fatalf("unexpected order: %s, %s, %s", repos[0].Name, repos[1].Name, repos[2].Name)
}
}
func TestDocSortOrder(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{
{URLPath: "CLAUDE"},
{URLPath: "zebra"},
{URLPath: "README"},
{URLPath: "ARCHITECTURE"},
{URLPath: "alpha"},
{URLPath: "RUNBOOK"},
},
})
repo, _ := c.GetRepo("r")
expected := []string{"README", "ARCHITECTURE", "RUNBOOK", "CLAUDE", "alpha", "zebra"}
for i, doc := range repo.Docs {
if doc.URLPath != expected[i] {
t.Errorf("position %d: got %q, want %q", i, doc.URLPath, expected[i])
}
}
}
func TestReadyState(t *testing.T) {
c := New()
if c.IsReady() {
t.Fatal("new cache should not be ready")
}
c.SetReady()
if !c.IsReady() {
t.Fatal("cache should be ready after SetReady")
}
}
func TestGetCommitSHA(t *testing.T) {
c := New()
if sha := c.GetCommitSHA("nope"); sha != "" {
t.Fatalf("expected empty sha, got %q", sha)
}
c.SetRepo(&RepoInfo{Name: "r", CommitSHA: "abc123"})
if sha := c.GetCommitSHA("r"); sha != "abc123" {
t.Fatalf("expected abc123, got %q", sha)
}
}
func TestAtomicRepoSwap(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{{URLPath: "old", Title: "Old"}},
})
c.SetRepo(&RepoInfo{
Name: "r",
Docs: []*Document{{URLPath: "new", Title: "New"}},
})
repo, _ := c.GetRepo("r")
if len(repo.Docs) != 1 || repo.Docs[0].URLPath != "new" {
t.Fatal("expected atomic swap to replace docs")
}
}
func TestRemoveRepo(t *testing.T) {
c := New()
c.SetRepo(&RepoInfo{Name: "r"})
c.RemoveRepo("r")
_, ok := c.GetRepo("r")
if ok {
t.Fatal("expected repo to be removed")
}
}
// Ensure Document's Headings field works with the render package type.
func TestDocumentHeadingsType(t *testing.T) {
doc := &Document{
Headings: []render.Heading{
{Level: 1, ID: "title", Text: "Title"},
},
}
if doc.Headings[0].Text != "Title" {
t.Fatal("unexpected heading text")
}
}

164
internal/config/config.go Normal file
View File

@@ -0,0 +1,164 @@
package config
import (
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
type Config struct {
Server ServerConfig `toml:"server"`
Gitea GiteaConfig `toml:"gitea"`
Log LogConfig `toml:"log"`
}
type ServerConfig struct {
ListenAddr string `toml:"listen_addr"`
}
type GiteaConfig struct {
URL string `toml:"url"`
Org string `toml:"org"`
WebhookSecret string `toml:"webhook_secret"`
PollInterval Duration `toml:"poll_interval"`
FetchTimeout Duration `toml:"fetch_timeout"`
MaxConcurrency int `toml:"max_concurrency"`
ExcludePaths ExcludePaths `toml:"exclude_paths"`
ExcludeRepos ExcludeRepos `toml:"exclude_repos"`
}
type ExcludePaths struct {
Patterns []string `toml:"patterns"`
}
type ExcludeRepos struct {
Names []string `toml:"names"`
}
type LogConfig struct {
Level string `toml:"level"`
}
// Duration wraps time.Duration for TOML string parsing.
type Duration struct {
time.Duration
}
func (d *Duration) UnmarshalText(text []byte) error {
var err error
d.Duration, err = time.ParseDuration(string(text))
return err
}
func (d Duration) MarshalText() ([]byte, error) {
return []byte(d.String()), nil
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) // #nosec G304 -- config path is operator-controlled
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
cfg := &Config{
Server: ServerConfig{
ListenAddr: ":8080",
},
Gitea: GiteaConfig{
URL: "https://git.wntrmute.dev",
Org: "mc",
PollInterval: Duration{15 * time.Minute},
FetchTimeout: Duration{30 * time.Second},
MaxConcurrency: 4,
ExcludePaths: ExcludePaths{
Patterns: []string{"vendor/", ".claude/", "node_modules/", ".junie/"},
},
},
Log: LogConfig{
Level: "info",
},
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyEnvOverrides(cfg)
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return cfg, nil
}
func (c *Config) validate() error {
if c.Server.ListenAddr == "" {
return fmt.Errorf("server.listen_addr is required")
}
if c.Gitea.URL == "" {
return fmt.Errorf("gitea.url is required")
}
if c.Gitea.Org == "" {
return fmt.Errorf("gitea.org is required")
}
if c.Gitea.MaxConcurrency < 1 {
return fmt.Errorf("gitea.max_concurrency must be >= 1")
}
return nil
}
// applyEnvOverrides checks for MCDOC_* environment variables and applies
// them to the config. Also checks $PORT for MCP agent port assignment.
func applyEnvOverrides(cfg *Config) {
if port := os.Getenv("PORT"); port != "" {
cfg.Server.ListenAddr = ":" + port
}
applyEnvToStruct("MCDOC", reflect.ValueOf(cfg).Elem())
}
func applyEnvToStruct(prefix string, v reflect.Value) {
t := v.Type()
for i := range t.NumField() {
field := t.Field(i)
fv := v.Field(i)
tag := field.Tag.Get("toml")
if tag == "" || tag == "-" {
continue
}
envKey := prefix + "_" + strings.ToUpper(tag)
if fv.Kind() == reflect.Struct && field.Type != reflect.TypeOf(Duration{}) {
applyEnvToStruct(envKey, fv)
continue
}
envVal := os.Getenv(envKey)
if envVal == "" {
continue
}
switch fv.Kind() {
case reflect.String:
fv.SetString(envVal)
case reflect.Int:
var n int
if _, err := fmt.Sscanf(envVal, "%d", &n); err == nil {
fv.SetInt(int64(n))
}
}
if field.Type == reflect.TypeOf(Duration{}) {
if d, err := time.ParseDuration(envVal); err == nil {
fv.Set(reflect.ValueOf(Duration{d}))
}
}
}
}

View File

@@ -0,0 +1,154 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadValidConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcdoc.toml")
content := `
[server]
listen_addr = ":9090"
[gitea]
url = "https://git.example.com"
org = "myorg"
webhook_secret = "secret123"
poll_interval = "5m"
fetch_timeout = "10s"
max_concurrency = 2
[gitea.exclude_paths]
patterns = ["vendor/"]
[gitea.exclude_repos]
names = ["ignore-me"]
[log]
level = "debug"
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Server.ListenAddr != ":9090" {
t.Fatalf("listen_addr = %q, want :9090", cfg.Server.ListenAddr)
}
if cfg.Gitea.URL != "https://git.example.com" {
t.Fatalf("gitea.url = %q", cfg.Gitea.URL)
}
if cfg.Gitea.Org != "myorg" {
t.Fatalf("gitea.org = %q", cfg.Gitea.Org)
}
if cfg.Gitea.PollInterval.Duration != 5*time.Minute {
t.Fatalf("poll_interval = %v", cfg.Gitea.PollInterval.Duration)
}
if cfg.Gitea.FetchTimeout.Duration != 10*time.Second {
t.Fatalf("fetch_timeout = %v", cfg.Gitea.FetchTimeout.Duration)
}
if cfg.Gitea.MaxConcurrency != 2 {
t.Fatalf("max_concurrency = %d", cfg.Gitea.MaxConcurrency)
}
if len(cfg.Gitea.ExcludePaths.Patterns) != 1 {
t.Fatalf("exclude_paths = %v", cfg.Gitea.ExcludePaths.Patterns)
}
if len(cfg.Gitea.ExcludeRepos.Names) != 1 {
t.Fatalf("exclude_repos = %v", cfg.Gitea.ExcludeRepos.Names)
}
if cfg.Log.Level != "debug" {
t.Fatalf("log.level = %q", cfg.Log.Level)
}
}
func TestLoadDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcdoc.toml")
// Minimal config — everything should get defaults
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Server.ListenAddr != ":8080" {
t.Fatalf("default listen_addr = %q, want :8080", cfg.Server.ListenAddr)
}
if cfg.Gitea.URL != "https://git.wntrmute.dev" {
t.Fatalf("default gitea.url = %q", cfg.Gitea.URL)
}
if cfg.Gitea.PollInterval.Duration != 15*time.Minute {
t.Fatalf("default poll_interval = %v", cfg.Gitea.PollInterval.Duration)
}
}
func TestLoadMissingFile(t *testing.T) {
_, err := Load("/nonexistent/mcdoc.toml")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestPortEnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcdoc.toml")
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("PORT", "12345")
cfg, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Server.ListenAddr != ":12345" {
t.Fatalf("PORT override: got %q, want :12345", cfg.Server.ListenAddr)
}
}
func TestEnvOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcdoc.toml")
if err := os.WriteFile(path, []byte(""), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
t.Setenv("MCDOC_GITEA_ORG", "custom-org")
cfg, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Gitea.Org != "custom-org" {
t.Fatalf("env override: got %q, want custom-org", cfg.Gitea.Org)
}
}
func TestValidationFailsOnEmptyURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcdoc.toml")
content := `
[gitea]
url = ""
`
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected validation error for empty gitea.url")
}
}

161
internal/gitea/client.go Normal file
View File

@@ -0,0 +1,161 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Repo represents a Gitea repository.
type Repo struct {
Name string `json:"name"`
Description string `json:"description"`
DefaultBranch string `json:"default_branch"`
}
// TreeEntry represents a file in a Gitea repo tree.
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"` // "blob" or "tree"
}
// TreeResponse is the Gitea API response for a recursive tree listing.
type TreeResponse struct {
Tree []TreeEntry `json:"tree"`
}
// CommitMeta holds minimal commit info for a file.
type CommitMeta struct {
SHA string `json:"sha"`
Date time.Time `json:"-"`
}
// RepoCommit is used to extract the latest commit SHA for a repo.
type RepoCommit struct {
SHA string `json:"sha"`
Commit repoCommitInfo `json:"commit"`
}
type repoCommitInfo struct {
Committer commitPerson `json:"committer"`
}
type commitPerson struct {
Date time.Time `json:"date"`
}
// Client fetches content from a Gitea instance.
type Client struct {
baseURL string
org string
httpClient *http.Client
}
// NewClient creates a Gitea API client.
func NewClient(baseURL, org string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
org: org,
httpClient: &http.Client{
Timeout: timeout,
},
}
}
// ListRepos returns all repositories in the configured organization.
func (c *Client) ListRepos(ctx context.Context) ([]Repo, error) {
var allRepos []Repo
page := 1
for {
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?page=%d&limit=50", c.baseURL, c.org, page)
var repos []Repo
if err := c.getJSON(ctx, url, &repos); err != nil {
return nil, fmt.Errorf("list repos page %d: %w", page, err)
}
if len(repos) == 0 {
break
}
allRepos = append(allRepos, repos...)
page++
}
return allRepos, nil
}
// ListMarkdownFiles returns paths to all .md files in a repo's default branch.
func (c *Client) ListMarkdownFiles(ctx context.Context, repo, branch string) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
c.baseURL, c.org, repo, branch)
var tree TreeResponse
if err := c.getJSON(ctx, url, &tree); err != nil {
return nil, fmt.Errorf("list tree for %s: %w", repo, err)
}
var files []string
for _, entry := range tree.Tree {
if entry.Type == "blob" && strings.HasSuffix(strings.ToLower(entry.Path), ".md") {
files = append(files, entry.Path)
}
}
return files, nil
}
// FetchFileContent returns the raw content of a file in a repo.
func (c *Client) FetchFileContent(ctx context.Context, repo, branch, filepath string) ([]byte, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
c.baseURL, c.org, repo, filepath, branch)
return c.getRaw(ctx, url)
}
// LatestCommitSHA returns the SHA of the latest commit on a branch.
func (c *Client) LatestCommitSHA(ctx context.Context, repo, branch string) (string, time.Time, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?sha=%s&limit=1",
c.baseURL, c.org, repo, branch)
var commits []RepoCommit
if err := c.getJSON(ctx, url, &commits); err != nil {
return "", time.Time{}, fmt.Errorf("latest commit for %s: %w", repo, err)
}
if len(commits) == 0 {
return "", time.Time{}, fmt.Errorf("no commits found for %s/%s", repo, branch)
}
return commits[0].SHA, commits[0].Commit.Committer.Date, nil
}
func (c *Client) getJSON(ctx context.Context, url string, target interface{}) error {
body, err := c.getRaw(ctx, url)
if err != nil {
return err
}
return json.Unmarshal(body, target)
}
func (c *Client) getRaw(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s: status %d", url, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response from %s: %w", url, err)
}
return body, nil
}

123
internal/render/render.go Normal file
View File

@@ -0,0 +1,123 @@
package render
import (
"bytes"
"fmt"
"regexp"
"strings"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
)
// Heading represents a heading extracted from a document for TOC generation.
type Heading struct {
Level int
ID string
Text string
}
// Result holds the rendered HTML and extracted metadata.
type Result struct {
HTML string
Headings []Heading
}
// Renderer converts markdown to HTML using goldmark.
type Renderer struct {
md goldmark.Markdown
}
// New creates a Renderer with GFM, syntax highlighting, and heading anchors.
func New() *Renderer {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithStyle("github"),
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
)
return &Renderer{md: md}
}
// Render converts markdown source to HTML and extracts headings.
func (r *Renderer) Render(source []byte) (*Result, error) {
var buf bytes.Buffer
if err := r.md.Convert(source, &buf); err != nil {
return nil, fmt.Errorf("render markdown: %w", err)
}
headings := extractHeadings(source, r.md.Parser())
return &Result{
HTML: buf.String(),
Headings: headings,
}, nil
}
func extractHeadings(source []byte, p parser.Parser) []Heading {
reader := text.NewReader(source)
doc := p.Parse(reader)
var headings []Heading
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
heading, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
id, _ := n.AttributeString("id")
idStr := ""
if idBytes, ok := id.([]byte); ok {
idStr = string(idBytes)
}
var textBuf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if t, ok := child.(*ast.Text); ok {
textBuf.Write(t.Segment.Value(source))
}
}
headings = append(headings, Heading{
Level: heading.Level,
ID: idStr,
Text: textBuf.String(),
})
return ast.WalkContinue, nil
})
return headings
}
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
// Slugify creates a URL-safe slug from text, matching goldmark's auto heading IDs.
func Slugify(text string) string {
text = strings.ToLower(text)
text = nonAlphanumeric.ReplaceAllString(text, "-")
text = strings.Trim(text, "-")
return text
}

View File

@@ -0,0 +1,99 @@
package render
import (
"strings"
"testing"
)
func TestRenderBasicMarkdown(t *testing.T) {
r := New()
result, err := r.Render([]byte("# Hello\n\nThis is a paragraph."))
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(result.HTML, "<h1") {
t.Fatalf("expected h1 tag, got: %s", result.HTML)
}
if !strings.Contains(result.HTML, "Hello") {
t.Fatalf("expected heading text, got: %s", result.HTML)
}
if !strings.Contains(result.HTML, "<p>This is a paragraph.</p>") {
t.Fatalf("expected paragraph, got: %s", result.HTML)
}
}
func TestRenderGFMTable(t *testing.T) {
md := "| A | B |\n|---|---|\n| 1 | 2 |"
r := New()
result, err := r.Render([]byte(md))
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(result.HTML, "<table>") {
t.Fatalf("expected table, got: %s", result.HTML)
}
}
func TestRenderCodeHighlighting(t *testing.T) {
md := "```go\nfunc main() {}\n```"
r := New()
result, err := r.Render([]byte(md))
if err != nil {
t.Fatalf("render: %v", err)
}
// chroma with classes should produce class attributes
if !strings.Contains(result.HTML, "class=") {
t.Fatalf("expected syntax highlighting classes, got: %s", result.HTML)
}
}
func TestExtractHeadings(t *testing.T) {
md := "# Title\n## Section\n### Subsection"
r := New()
result, err := r.Render([]byte(md))
if err != nil {
t.Fatalf("render: %v", err)
}
if len(result.Headings) != 3 {
t.Fatalf("expected 3 headings, got %d", len(result.Headings))
}
if result.Headings[0].Level != 1 || result.Headings[0].Text != "Title" {
t.Fatalf("unexpected first heading: %+v", result.Headings[0])
}
if result.Headings[1].Level != 2 || result.Headings[1].Text != "Section" {
t.Fatalf("unexpected second heading: %+v", result.Headings[1])
}
}
func TestHeadingAnchors(t *testing.T) {
md := "# Hello World"
r := New()
result, err := r.Render([]byte(md))
if err != nil {
t.Fatalf("render: %v", err)
}
if len(result.Headings) == 0 {
t.Fatal("no headings extracted")
}
if result.Headings[0].ID == "" {
t.Fatal("expected heading ID to be set")
}
}
func TestSlugify(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Hello World", "hello-world"},
{"API Design", "api-design"},
{"foo--bar", "foo-bar"},
{" leading spaces ", "leading-spaces"},
}
for _, tt := range tests {
got := Slugify(tt.input)
if got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

218
internal/server/fetch.go Normal file
View 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
View 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
}

View 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)
}
}
}