Files
claude-timemachine aa36b2905a 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>
2026-05-13 00:52:53 +02:00

93 lines
4.5 KiB
Markdown

# automc-tutorials
Public-facing tutorials site for the automc Minecraft platform. Renders markdown from a mounted volume as a localized static-ish web app. No auth, no database, no state — exposed to the open internet behind TLS, hardened for zero-trust.
## Stack
- **Go** + `net/http` + `html/template` — single binary
- **goldmark** — markdown → HTML (GFM + footnotes + auto heading IDs)
- **Plain CSS** — no framework, dark mode via `prefers-color-scheme`
- **Vanilla JS** — ~70-line client-side substring search, no dependency
- Content lives on a mounted volume; reload via SIGHUP
## Layout
```
cmd/automc-tutorials/main.go — entry: flags, signals, SIGHUP reload
internal/content/ — loader + path-traversal guard for content/<locale>/<category>/<slug>.md
internal/render/ — goldmark wrapper (HTML escaping on)
internal/server/ — mux, handlers, embedded templates + /static/ bundle
content/<locale>/<category>/ — markdown source. _index.md is the category landing page (optional).
```
## Adding or editing a tutorial
The image carries only the binary. Markdown sits on a volume mounted at `/content` (default in the image; override with `CONTENT_DIR`). Workflow:
1. Open an admin shell on the host that owns the PVC (or shell into a pod that mounts the same RWX volume).
2. Drop / edit `.md` files under `<volume>/<locale>/<category>/`.
3. Add YAML frontmatter:
```yaml
---
title: Page title
summary: One-sentence preview shown on category list.
order: 1
---
```
4. Write markdown. GFM tables, fenced code, footnotes, autolinks, task lists all work. Raw HTML in markdown is **escaped** — don't try to embed `<script>` or `<iframe>`, they'll render as text.
5. Reload without restart: `kill -HUP $(pidof automc-tutorials)` inside the container, or `kubectl exec deploy/automc-tutorials -- kill -HUP 1`.
No rebuild, no rollout. The atomic library swap means in-flight requests keep serving the old library; new requests see the new one.
## Localization
Each top-level directory under the content root is a locale. Pages are independent per locale — translations are not auto-mapped. The header has a locale switcher that just changes `/<locale>/` in the URL.
If a page exists in `en` but not `cs`, switching to `cs` lands on the `cs` home (no automatic redirect, no fallback render).
## Search
Client-side substring search over `title`, `summary`, and `body`. Index served at `GET /search.json?lang=<locale>` — flat array of `{path, title, summary, body}`. `static/search.js` does a case-insensitive linear scan with crude scoring (title hit = 100, summary = 10, body = 1) and shows the top 8.
This is fine for a few hundred pages. If the corpus ever balloons, swap the in-browser scan for a real indexer — the endpoint contract is stable.
## Configuration
| Var | Default | Purpose |
|---|---|---|
| `ADDR` | `:8080` | HTTP listen address |
| `CONTENT_DIR` | `/content` | Path to content root (volume mount in production, `./content` in dev) |
| `DEFAULT_LOCALE` | `en` | Locale used when none in URL (`/` → `/<locale>/`) |
## Zero-trust posture
The service is intended to sit behind TLS (k8s ingress) on the open internet, with no auth and no WAF. Hardening:
- **No HTML in markdown.** `goldmark.WithUnsafe()` is not enabled, so raw HTML in `.md` is escaped.
- **CSP** restricts everything to `'self'`; `'unsafe-inline'` is absent (templates carry no inline styles or scripts). Frame ancestors blocked, base/form-action pinned to self.
- **HSTS, COOP, CORP, Permissions-Policy** all set conservatively.
- **No directory listing** on `/static/`; only GET/HEAD; hidden files (`/.foo`) blocked.
- **Content loader** rejects symlinks that escape the content root, hidden files, and `.md` files larger than 1 MiB.
- **HTTP timeouts** are set on read header, read body, write, and idle.
- **Distroless static, runs as 65532:65532**, no shell, no package manager.
- **No outbound calls** — the service never reaches out to anything.
## Build
```fish
make build # binary
make dev # go run, no build artifact
make test # go test ./...
make docker # Docker image
```
## Deployment
Single binary, distroless image, port 8080, no persistent state. Deploy as a normal k8s `Deployment` + `Service` + `Ingress` for `tutorials.timemachine.center`. Mount the content PVC read-only at `/content`. No DB, no auth, no secrets.
## Repo
Standalone Gitea repo at `git.timemachine.center/Timemachine/automc-tutorials`. Sibling of the automc orchestration repo.