initial: zero-trust markdown tutorials site
Single-binary Go service that renders markdown pages from a runtime volume mount. Targeted at public, no-auth, no-WAF deployment behind a TLS ingress; security posture is defense-in-depth at every layer: - goldmark with no WithUnsafe — raw HTML in author markdown is stripped - CSP without 'unsafe-inline', plus HSTS, COOP, CORP, Permissions-Policy - static handler rejects non-GET/HEAD, directory listings, dotfiles, traversal - content loader rejects symlinks that escape the content root, dotfiles, and .md files larger than 1 MiB - per-page template trees (cloned from layout) so define-blocks don't collide between home/category/page - SIGHUP triggers atomic library swap — live edits on volume, no rebuild Locale layout content/<locale>/<category>/<slug>.md. Categories without _index.md still appear on the home page with a humanized name. Search is a ~70-line vanilla JS scan over /search.json?lang=<locale>; swap for a real indexer if the corpus ever balloons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// Package content loads and indexes markdown tutorial pages from disk.
|
||||
//
|
||||
// Layout on disk:
|
||||
//
|
||||
// content/
|
||||
// <locale>/ — e.g. en, cs
|
||||
// <category>/ — e.g. install-client, registration, troubleshooting
|
||||
// _index.md — category landing page (optional)
|
||||
// <slug>.md — individual tutorial page
|
||||
//
|
||||
// Each markdown file may have YAML frontmatter delimited by `---`:
|
||||
//
|
||||
// ---
|
||||
// title: Install Minecraft Java Edition
|
||||
// order: 1
|
||||
// summary: Step-by-step for installing the official launcher.
|
||||
// ---
|
||||
// # heading...
|
||||
//
|
||||
// Pages without frontmatter use the filename as title and order=999.
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Library is an in-memory index of all loaded pages. Construct via Load.
|
||||
// Read-only after construction; the server swaps whole Library values on reload.
|
||||
type Library struct {
|
||||
pages map[string]*Page // key: <locale>/<category>/<slug>
|
||||
byPath map[string][]*Page // key: <locale>/<category>, value: pages sorted by Order then Title
|
||||
locales []string // sorted, unique
|
||||
categs map[string]map[string]*Page // <locale> → <category> → category-index page (or nil if no _index.md)
|
||||
}
|
||||
|
||||
// maxPageBytes caps the size of a single markdown file. Loader skips bigger files with a stderr warning.
|
||||
// Content is operator-controlled (volume mount), but this prevents a stray dump from ballooning memory.
|
||||
const maxPageBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Page is one markdown document.
|
||||
type Page struct {
|
||||
Locale string // "en", "cs"
|
||||
Category string // "install-client"
|
||||
Slug string // "java-edition", "_index"
|
||||
Title string
|
||||
Summary string
|
||||
Order int
|
||||
Source []byte // raw markdown body (no frontmatter)
|
||||
Path string // <locale>/<category>/<slug>
|
||||
}
|
||||
|
||||
type frontmatter struct {
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Order int `yaml:"order"`
|
||||
}
|
||||
|
||||
// Load walks contentDir and returns a populated Library.
|
||||
// Returns an error only on filesystem-level failures; individual malformed pages are skipped with a warning written to stderr.
|
||||
//
|
||||
// Zero-trust posture: the content dir is volume-mounted in production. The loader
|
||||
// resolves the absolute path of contentDir once and rejects any entry (dir or file)
|
||||
// whose evaluated symlink target falls outside it. This prevents a misconfigured
|
||||
// volume (or an operator slipping in a symlink) from reading host files.
|
||||
func Load(contentDir string) (*Library, error) {
|
||||
info, err := os.Stat(contentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat content dir %q: %w", contentDir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("content path %q is not a directory", contentDir)
|
||||
}
|
||||
rootAbs, err := filepath.Abs(contentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs content dir: %w", err)
|
||||
}
|
||||
rootAbs, err = filepath.EvalSymlinks(rootAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eval root symlinks: %w", err)
|
||||
}
|
||||
|
||||
lib := &Library{
|
||||
pages: make(map[string]*Page),
|
||||
byPath: make(map[string][]*Page),
|
||||
categs: make(map[string]map[string]*Page),
|
||||
}
|
||||
|
||||
localeEntries, err := os.ReadDir(contentDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read content dir: %w", err)
|
||||
}
|
||||
for _, lEntry := range localeEntries {
|
||||
name := lEntry.Name()
|
||||
if strings.HasPrefix(name, ".") || !lEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
localeDir := filepath.Join(contentDir, name)
|
||||
if !withinRoot(rootAbs, localeDir) {
|
||||
fmt.Fprintf(os.Stderr, "[content] skipping locale %q: escapes content root\n", name)
|
||||
continue
|
||||
}
|
||||
lib.locales = append(lib.locales, name)
|
||||
lib.categs[name] = make(map[string]*Page)
|
||||
|
||||
categEntries, err := os.ReadDir(localeDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read locale %q: %w", name, err)
|
||||
}
|
||||
for _, cEntry := range categEntries {
|
||||
cName := cEntry.Name()
|
||||
if strings.HasPrefix(cName, ".") || !cEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
categDir := filepath.Join(localeDir, cName)
|
||||
if !withinRoot(rootAbs, categDir) {
|
||||
fmt.Fprintf(os.Stderr, "[content] skipping %s/%s: escapes content root\n", name, cName)
|
||||
continue
|
||||
}
|
||||
// Register the category even if _index.md is missing; it'll show up
|
||||
// on the home page with a humanized name.
|
||||
if _, ok := lib.categs[name][cName]; !ok {
|
||||
lib.categs[name][cName] = nil
|
||||
}
|
||||
if err := lib.loadCategory(rootAbs, name, cName, categDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(lib.locales)
|
||||
for k := range lib.byPath {
|
||||
pages := lib.byPath[k]
|
||||
sort.SliceStable(pages, func(i, j int) bool {
|
||||
if pages[i].Order != pages[j].Order {
|
||||
return pages[i].Order < pages[j].Order
|
||||
}
|
||||
return pages[i].Title < pages[j].Title
|
||||
})
|
||||
lib.byPath[k] = pages
|
||||
}
|
||||
return lib, nil
|
||||
}
|
||||
|
||||
// withinRoot returns true if p, after resolving symlinks, is rootAbs or a descendant.
|
||||
// Used to reject volume entries that point outside the content directory.
|
||||
func withinRoot(rootAbs, p string) bool {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, resolved)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
|
||||
}
|
||||
|
||||
func (l *Library) loadCategory(rootAbs, locale, category, dir string) error {
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pathKey := locale + "/" + category
|
||||
for _, f := range files {
|
||||
name := f.Name()
|
||||
if strings.HasPrefix(name, ".") || f.IsDir() || !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
fp := filepath.Join(dir, name)
|
||||
if !withinRoot(rootAbs, fp) {
|
||||
fmt.Fprintf(os.Stderr, "[content] skipping %s: escapes content root\n", fp)
|
||||
continue
|
||||
}
|
||||
// Use Stat on the resolved path to enforce size cap before reading.
|
||||
info, err := os.Stat(fp)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[content] stat %s: %v\n", fp, err)
|
||||
continue
|
||||
}
|
||||
if info.Size() > maxPageBytes {
|
||||
fmt.Fprintf(os.Stderr, "[content] skipping %s: %d bytes exceeds %d cap\n", fp, info.Size(), maxPageBytes)
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(name, ".md")
|
||||
raw, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := parsePage(raw, locale, category, slug)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[content] skipping %s: %v\n", fp, err)
|
||||
continue
|
||||
}
|
||||
key := page.Path
|
||||
l.pages[key] = page
|
||||
if slug == "_index" {
|
||||
l.categs[locale][category] = page
|
||||
} else {
|
||||
l.byPath[pathKey] = append(l.byPath[pathKey], page)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePage(raw []byte, locale, category, slug string) (*Page, error) {
|
||||
body := raw
|
||||
fm := frontmatter{Order: 999}
|
||||
if bytes := raw; len(bytes) >= 4 && string(bytes[:3]) == "---" {
|
||||
// Find closing fence on its own line.
|
||||
rest := bytes[3:]
|
||||
idx := strings.Index(string(rest), "\n---")
|
||||
if idx >= 0 {
|
||||
head := rest[:idx]
|
||||
if err := yaml.Unmarshal(head, &fm); err != nil {
|
||||
return nil, fmt.Errorf("frontmatter: %w", err)
|
||||
}
|
||||
body = rest[idx+4:]
|
||||
body = trimLeadingNewline(body)
|
||||
}
|
||||
}
|
||||
if fm.Title == "" {
|
||||
fm.Title = slugToTitle(slug)
|
||||
}
|
||||
return &Page{
|
||||
Locale: locale,
|
||||
Category: category,
|
||||
Slug: slug,
|
||||
Title: fm.Title,
|
||||
Summary: fm.Summary,
|
||||
Order: fm.Order,
|
||||
Source: body,
|
||||
Path: locale + "/" + category + "/" + slug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func trimLeadingNewline(b []byte) []byte {
|
||||
if len(b) > 0 && b[0] == '\n' {
|
||||
return b[1:]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func slugToTitle(slug string) string {
|
||||
s := strings.ReplaceAll(slug, "-", " ")
|
||||
s = strings.ReplaceAll(s, "_", " ")
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
func (l *Library) Locales() []string { return append([]string(nil), l.locales...) }
|
||||
func (l *Library) PageCount() int { return len(l.pages) }
|
||||
func (l *Library) HasLocale(loc string) bool {
|
||||
_, ok := l.categs[loc]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Categories returns the categories in a locale, sorted by their _index.Order then name.
|
||||
type Category struct {
|
||||
Name string
|
||||
Index *Page // may be nil if no _index.md
|
||||
Pages []*Page
|
||||
}
|
||||
|
||||
func (l *Library) Categories(locale string) []Category {
|
||||
cats, ok := l.categs[locale]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []Category
|
||||
for name := range cats {
|
||||
idx := cats[name]
|
||||
out = append(out, Category{
|
||||
Name: name,
|
||||
Index: idx,
|
||||
Pages: l.byPath[locale+"/"+name],
|
||||
})
|
||||
}
|
||||
// Stable order: by index Order, then name.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
oi, oj := 999, 999
|
||||
if out[i].Index != nil {
|
||||
oi = out[i].Index.Order
|
||||
}
|
||||
if out[j].Index != nil {
|
||||
oj = out[j].Index.Order
|
||||
}
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Page returns the page for a path or nil.
|
||||
func (l *Library) Page(locale, category, slug string) *Page {
|
||||
return l.pages[locale+"/"+category+"/"+slug]
|
||||
}
|
||||
|
||||
// AllPages returns every page; used by the search index endpoint.
|
||||
func (l *Library) AllPages() []*Page {
|
||||
out := make([]*Page, 0, len(l.pages))
|
||||
for _, p := range l.pages {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user