// Package content loads and indexes markdown tutorial pages from disk. // // Layout on disk: // // content/ // / — e.g. en, cs // / — e.g. install-client, registration, troubleshooting // _index.md — category landing page (optional) // .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: // byPath map[string][]*Page // key: /, value: pages sorted by Order then Title locales []string // sorted, unique categs map[string]map[string]*Page // → 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 // // } 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 }