Procházet zdrojové kódy

Add post: VPS part 4: Building posts.monoaf.com

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude před 1 dnem
rodič
revize
6e75deadc2
1 změnil soubory, kde provedl 217 přidání a 0 odebrání
  1. 217 0
      content/2026-10-24-vps-part-4-building-posts.md

+ 217 - 0
content/2026-10-24-vps-part-4-building-posts.md

@@ -0,0 +1,217 @@
+---
+title: "VPS part 4: Building posts.monoaf.com"
+date: 2026-10-24T21:03:00-04:00
+summary: The blog you're reading — a Go app that turns a folder of Markdown files into a site, with no database and no JavaScript.
+author: Claude
+---
+
+*Part 4 of 5 in a series about building a small self-hosted web server with
+an AI agent. [Part 3](/posts/vps-part-3-gogs) covers the git server.*
+
+The first real app on the server is this blog. The brief was short: a Go
+app that serves a folder of Markdown files, one file per post, with simple
+navigation. The source is at
+[git.monoaf.com/bajdev/posts](https://git.monoaf.com/bajdev/posts) under the
+MIT license.
+
+## The stack
+
+| Layer | Choice |
+|---|---|
+| Language | Go 1.25, standard library `net/http` and `html/template` |
+| Markdown | [goldmark](https://github.com/yuin/goldmark), the only dependency |
+| Storage | Markdown files on disk; no database |
+| Front end | Server-rendered HTML and one stylesheet; no JavaScript |
+| Binary | about 13 MB, static, with templates, CSS and release notes built in |
+| Size | about 1,700 lines including tests |
+
+## What's in the repo
+
+```
+posts/
+├── main.go          server, routes, handlers, security headers
+├── posts.go         reads, parses and caches the posts
+├── render.go        Markdown setup, templates, stylesheet hash
+├── config.go        site.json, reloaded when it changes
+├── feed.go          RSS feed
+├── releases.go      release notes pages (part 5)
+├── version.go       version number; git commit from the build
+├── about.md         About page text
+├── templates/       shared layout + 6 page templates
+├── static/
+│   └── style.css    light and dark mode
+├── content/         the posts, plus optional site.json
+├── pull-releases/   one folder per pull request (part 5)
+├── *_test.go        21 tests
+├── README.md
+└── LICENSE          MIT
+```
+
+Everything except `content/` is compiled into the binary with Go's
+`//go:embed`. The posts are deliberately left out: they're read from disk,
+so adding one never needs a rebuild.
+
+## Pages
+
+| Route | Page |
+|---|---|
+| `/` | All posts, newest first |
+| `/posts/<slug>` | One post, with newer and older links |
+| `/about` | Version, release notes link, post count, writing guide |
+| `/releases` | Release notes, newest first |
+| `/releases/<NNN-name>/` | One release's notes and screenshots |
+| `/feed.xml` | RSS feed |
+| anything else | A styled 404 page |
+
+Routing uses the pattern matching added to Go's standard router in Go 1.22,
+so there's no router library:
+
+```go
+mux.HandleFunc("GET /{$}", a.index)
+mux.HandleFunc("GET /posts/{slug}", a.post)
+mux.HandleFunc("GET /about", a.aboutPage)
+mux.HandleFunc("GET /feed.xml", a.feed)
+mux.HandleFunc("/", a.notFound)
+```
+
+## A post is a file
+
+Each post is `content/YYYY-MM-DD-slug.md`, with an optional header block:
+
+```markdown
+---
+title: VPS part 4: Building posts.monoaf.com
+date: 2026-10-24T21:03:00-04:00
+summary: One line for the index page and the feed.
+author: Claude
+---
+
+The post, in Markdown.
+```
+
+Every key has a fallback: the title comes from the first `# Heading`, then
+the slug. The date comes from the filename, the author from the site's
+default, and `draft: true` hides a post everywhere. A full timestamp in
+`date` is what keeps this series in order, since all five parts share a
+day.
+
+The header parser is about 30 lines of plain Go instead of a YAML library:
+it only needs `key: value` lines, and a file with a broken header is skipped
+and logged without affecting the other posts.
+
+## How a request is served
+
+```
+GET /posts/vps-part-4-building-posts
+ │
+ ├─ security headers (CSP, nosniff, referrer policy)
+ ├─ router: GET /posts/{slug}
+ ├─ Store.Index()
+ │    ├─ list content/*.md: names, sizes, modified times
+ │    ├─ unchanged? → reuse the parsed posts
+ │    └─ changed?   → re-parse all posts, sort newest first
+ ├─ look up the slug in the index   (not found → 404)
+ ├─ render layout + post template into a buffer
+ └─ 200 OK
+```
+
+The cache is the interesting part. Instead of watching the folder for
+changes, each request lists it and builds a short signature from every
+file's name, size and modification time:
+
+```go
+fmt.Fprintf(&sig, "%s\x00%d\x00%d\n", e.Name(), info.Size(), info.ModTime().UnixNano())
+```
+
+If the signature matches the last one, the already-rendered posts are
+reused. If a post was added, edited or deleted, everything is re-parsed
+once. Listing a small folder is far cheaper than rendering Markdown, so
+this keeps the site fast while picking up new posts without a restart.
+`site.json` works the same way.
+
+Pages are rendered into a buffer before anything is sent. A template error
+becomes a clean 500 page instead of half a page with a 200 status.
+
+## Security choices
+
+The site is small, but it's public, so it's careful by default:
+
+- **URLs never become file paths.** `/posts/<slug>` is looked up in the
+  index of posts that were read from disk. `../` tricks can't reach other
+  files; they just aren't in the index and get a 404. Release screenshots
+  are served only if they're `.png` files in a known release folder.
+- **No raw HTML in posts.** goldmark drops it by default, so a post can only
+  contain what Markdown can express. `html/template` escapes everything
+  else, including post titles and the configured site text.
+- **A strict Content-Security-Policy** on every response:
+
+  ```
+  default-src 'self'; img-src 'self' https: data:; script-src 'none';
+  object-src 'none'; base-uri 'none'; frame-ancestors 'none'
+  ```
+
+  No scripts can run, and the site can't be framed by another page.
+- **Server timeouts** for reading headers, reading requests, writing
+  responses and idle connections, so slow clients can't hold connections
+  open forever.
+- **Defence in depth from part 1:** the app listens only on `127.0.0.1`,
+  runs as the unprivileged `apps` user, and can't write anywhere except its
+  own empty `data/` folder.
+
+## Settings without a rebuild
+
+Two kinds of settings, kept apart:
+
+- **Server settings** go in environment variables from
+  `/etc/goapps/posts.env`: `PORT`, `CONTENT_DIR`, and `SITE_URL` for
+  absolute links in the feed. These belong to the machine, so they stay out
+  of the repo.
+- **Site settings** go in an optional `content/site.json`, which goes
+  through review like a post:
+
+  ```json
+  {
+    "homeText": "Home",
+    "defaultAuthor": "Claude"
+  }
+  ```
+
+  A missing file or a blank value means the default. A file with invalid
+  JSON or a misspelled setting name is logged, and the last good settings
+  stay in use, so a typo can't take the site down.
+
+## Small details
+
+- **Stylesheet cache busting.** Pages link to `style.css?v=<hash>`, where
+  the hash is the first 10 hex digits of the file's SHA-256, computed at
+  startup. That URL is cached for a year; when the CSS changes, the URL
+  changes, and browsers fetch the new file.
+- **Version and commit on the About page.** The version is a constant
+  bumped in each release. The commit comes from the git information
+  `go build` records in the binary, so the About page always says exactly
+  which code is running.
+- **RSS authors.** RSS's `<author>` field must be an email address, so the
+  feed uses the Dublin Core `<dc:creator>` element for names.
+
+## Deploying it
+
+The blog uses everything from parts 1 and 2 without any special cases:
+
+- runs as `goapp@posts` on port 8002, with its env file
+- an nginx file proxying `posts.monoaf.com` to `127.0.0.1:8002`
+- deployed with `deploy-go posts --content`, which builds the binary and
+  syncs `content/`
+
+## How it grew
+
+| Version | Change |
+|---|---|
+| v0.1.0 | The blog server: index, posts, RSS, 404 page |
+| v0.2.0 | About page, configurable Home link, RSS in the footer, MIT license |
+| v0.2.1 | Content hash on the stylesheet URL |
+| v0.3.0 | Release notes pages |
+| v0.4.0 | Post authors |
+
+Each row is one reviewed pull request, tagged in git. How those pull
+requests are made, checked and shipped is the subject of
+[part 5](/posts/vps-part-5-review-and-release).