@geekity/cms 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +445 -63
  2. package/admin/layouts/shell.njk +13 -6
  3. package/admin/pages/comments/all.njk +3 -0
  4. package/admin/pages/error.njk +11 -0
  5. package/admin/pages/settings/discussion.njk +53 -0
  6. package/admin/pages/settings/email.njk +20 -0
  7. package/admin/pages/tools/personal-data.njk +67 -0
  8. package/admin/pages/users/edit.njk +1 -1
  9. package/admin/static/admin.css +69 -15
  10. package/dist/admin/comments.d.ts +5 -0
  11. package/dist/admin/comments.d.ts.map +1 -1
  12. package/dist/admin/comments.js +13 -0
  13. package/dist/admin/comments.js.map +1 -1
  14. package/dist/admin/documents.d.ts.map +1 -1
  15. package/dist/admin/documents.js +58 -31
  16. package/dist/admin/documents.js.map +1 -1
  17. package/dist/admin/headers.d.ts +14 -5
  18. package/dist/admin/headers.d.ts.map +1 -1
  19. package/dist/admin/headers.js +18 -6
  20. package/dist/admin/headers.js.map +1 -1
  21. package/dist/admin/index.d.ts +1 -1
  22. package/dist/admin/index.d.ts.map +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.js.map +1 -1
  25. package/dist/admin/menu.d.ts.map +1 -1
  26. package/dist/admin/menu.js +5 -1
  27. package/dist/admin/menu.js.map +1 -1
  28. package/dist/admin/personal-data.d.ts +35 -0
  29. package/dist/admin/personal-data.d.ts.map +1 -0
  30. package/dist/admin/personal-data.js +90 -0
  31. package/dist/admin/personal-data.js.map +1 -0
  32. package/dist/admin/routes.d.ts.map +1 -1
  33. package/dist/admin/routes.js +23 -4
  34. package/dist/admin/routes.js.map +1 -1
  35. package/dist/admin/session.d.ts +42 -1
  36. package/dist/admin/session.d.ts.map +1 -1
  37. package/dist/admin/session.js +74 -4
  38. package/dist/admin/session.js.map +1 -1
  39. package/dist/admin/settings-discussion.d.ts.map +1 -1
  40. package/dist/admin/settings-discussion.js +9 -1
  41. package/dist/admin/settings-discussion.js.map +1 -1
  42. package/dist/admin/settings-email.d.ts.map +1 -1
  43. package/dist/admin/settings-email.js +10 -1
  44. package/dist/admin/settings-email.js.map +1 -1
  45. package/dist/admin/settings.d.ts +39 -0
  46. package/dist/admin/settings.d.ts.map +1 -1
  47. package/dist/admin/settings.js +144 -0
  48. package/dist/admin/settings.js.map +1 -1
  49. package/dist/admin/store.d.ts +30 -0
  50. package/dist/admin/store.d.ts.map +1 -1
  51. package/dist/admin/store.js +68 -5
  52. package/dist/admin/store.js.map +1 -1
  53. package/dist/admin/templates.d.ts +2 -0
  54. package/dist/admin/templates.d.ts.map +1 -1
  55. package/dist/admin/templates.js +2 -0
  56. package/dist/admin/templates.js.map +1 -1
  57. package/dist/avatars/avatars.d.ts +112 -0
  58. package/dist/avatars/avatars.d.ts.map +1 -0
  59. package/dist/avatars/avatars.js +259 -0
  60. package/dist/avatars/avatars.js.map +1 -0
  61. package/dist/avatars/index.d.ts +4 -0
  62. package/dist/avatars/index.d.ts.map +1 -0
  63. package/dist/avatars/index.js +3 -0
  64. package/dist/avatars/index.js.map +1 -0
  65. package/dist/avatars/routes.d.ts +11 -0
  66. package/dist/avatars/routes.d.ts.map +1 -0
  67. package/dist/avatars/routes.js +33 -0
  68. package/dist/avatars/routes.js.map +1 -0
  69. package/dist/cli.d.ts +1 -1
  70. package/dist/cli.d.ts.map +1 -1
  71. package/dist/cli.js +128 -3
  72. package/dist/cli.js.map +1 -1
  73. package/dist/comments/records.d.ts +15 -0
  74. package/dist/comments/records.d.ts.map +1 -1
  75. package/dist/comments/records.js +51 -1
  76. package/dist/comments/records.js.map +1 -1
  77. package/dist/comments/routes.d.ts.map +1 -1
  78. package/dist/comments/routes.js +7 -1
  79. package/dist/comments/routes.js.map +1 -1
  80. package/dist/config.d.ts +37 -0
  81. package/dist/config.d.ts.map +1 -1
  82. package/dist/config.js +58 -0
  83. package/dist/config.js.map +1 -1
  84. package/dist/contact/index.d.ts +1 -1
  85. package/dist/contact/index.d.ts.map +1 -1
  86. package/dist/contact/index.js +1 -1
  87. package/dist/contact/index.js.map +1 -1
  88. package/dist/contact/records.d.ts +8 -2
  89. package/dist/contact/records.d.ts.map +1 -1
  90. package/dist/contact/records.js +13 -6
  91. package/dist/contact/records.js.map +1 -1
  92. package/dist/content/document.d.ts +10 -2
  93. package/dist/content/document.d.ts.map +1 -1
  94. package/dist/content/document.js +1 -0
  95. package/dist/content/document.js.map +1 -1
  96. package/dist/content/parser.d.ts.map +1 -1
  97. package/dist/content/parser.js +13 -0
  98. package/dist/content/parser.js.map +1 -1
  99. package/dist/content/store.d.ts +13 -2
  100. package/dist/content/store.d.ts.map +1 -1
  101. package/dist/content/store.js +45 -1
  102. package/dist/content/store.js.map +1 -1
  103. package/dist/content/writer.d.ts.map +1 -1
  104. package/dist/content/writer.js +4 -0
  105. package/dist/content/writer.js.map +1 -1
  106. package/dist/env.d.ts +18 -0
  107. package/dist/env.d.ts.map +1 -1
  108. package/dist/federation/article.d.ts +9 -0
  109. package/dist/federation/article.d.ts.map +1 -1
  110. package/dist/federation/article.js +24 -2
  111. package/dist/federation/article.js.map +1 -1
  112. package/dist/federation/federation.d.ts.map +1 -1
  113. package/dist/federation/federation.js +24 -6
  114. package/dist/federation/federation.js.map +1 -1
  115. package/dist/federation/inbox.d.ts +17 -3
  116. package/dist/federation/inbox.d.ts.map +1 -1
  117. package/dist/federation/inbox.js +72 -6
  118. package/dist/federation/inbox.js.map +1 -1
  119. package/dist/federation/mount.d.ts.map +1 -1
  120. package/dist/federation/mount.js +8 -3
  121. package/dist/federation/mount.js.map +1 -1
  122. package/dist/federation/paths.d.ts +5 -0
  123. package/dist/federation/paths.d.ts.map +1 -1
  124. package/dist/federation/paths.js +5 -0
  125. package/dist/federation/paths.js.map +1 -1
  126. package/dist/federation/quotes.d.ts +58 -0
  127. package/dist/federation/quotes.d.ts.map +1 -0
  128. package/dist/federation/quotes.js +89 -0
  129. package/dist/federation/quotes.js.map +1 -0
  130. package/dist/federation/replies.d.ts +23 -0
  131. package/dist/federation/replies.d.ts.map +1 -1
  132. package/dist/federation/replies.js +47 -10
  133. package/dist/federation/replies.js.map +1 -1
  134. package/dist/federation/wordpress.d.ts.map +1 -1
  135. package/dist/federation/wordpress.js +5 -4
  136. package/dist/federation/wordpress.js.map +1 -1
  137. package/dist/index.d.ts +23 -2
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +64 -7
  140. package/dist/index.js.map +1 -1
  141. package/dist/maintenance.d.ts +47 -0
  142. package/dist/maintenance.d.ts.map +1 -0
  143. package/dist/maintenance.js +78 -0
  144. package/dist/maintenance.js.map +1 -0
  145. package/dist/notifications/index.d.ts +1 -1
  146. package/dist/notifications/index.d.ts.map +1 -1
  147. package/dist/notifications/index.js +1 -1
  148. package/dist/notifications/index.js.map +1 -1
  149. package/dist/notifications/optouts.d.ts +6 -0
  150. package/dist/notifications/optouts.d.ts.map +1 -1
  151. package/dist/notifications/optouts.js +17 -0
  152. package/dist/notifications/optouts.js.map +1 -1
  153. package/dist/privacy/erase.d.ts +45 -0
  154. package/dist/privacy/erase.d.ts.map +1 -0
  155. package/dist/privacy/erase.js +77 -0
  156. package/dist/privacy/erase.js.map +1 -0
  157. package/dist/privacy/policy.d.ts +32 -0
  158. package/dist/privacy/policy.d.ts.map +1 -0
  159. package/dist/privacy/policy.js +37 -0
  160. package/dist/privacy/policy.js.map +1 -0
  161. package/dist/privacy/retention.d.ts +79 -0
  162. package/dist/privacy/retention.d.ts.map +1 -0
  163. package/dist/privacy/retention.js +133 -0
  164. package/dist/privacy/retention.js.map +1 -0
  165. package/dist/web/conversation.d.ts +9 -1
  166. package/dist/web/conversation.d.ts.map +1 -1
  167. package/dist/web/conversation.js +69 -39
  168. package/dist/web/conversation.js.map +1 -1
  169. package/dist/web/documents.d.ts +9 -0
  170. package/dist/web/documents.d.ts.map +1 -1
  171. package/dist/web/documents.js +31 -0
  172. package/dist/web/documents.js.map +1 -1
  173. package/dist/web/errors.d.ts +19 -0
  174. package/dist/web/errors.d.ts.map +1 -0
  175. package/dist/web/errors.js +93 -0
  176. package/dist/web/errors.js.map +1 -0
  177. package/dist/web/health.d.ts +6 -0
  178. package/dist/web/health.d.ts.map +1 -1
  179. package/dist/web/health.js +5 -1
  180. package/dist/web/health.js.map +1 -1
  181. package/dist/web/index.d.ts +5 -0
  182. package/dist/web/index.d.ts.map +1 -1
  183. package/dist/web/index.js +4 -0
  184. package/dist/web/index.js.map +1 -1
  185. package/dist/web/maintenance.d.ts +18 -0
  186. package/dist/web/maintenance.d.ts.map +1 -0
  187. package/dist/web/maintenance.js +113 -0
  188. package/dist/web/maintenance.js.map +1 -0
  189. package/dist/web/redirects.d.ts +75 -0
  190. package/dist/web/redirects.d.ts.map +1 -0
  191. package/dist/web/redirects.js +200 -0
  192. package/dist/web/redirects.js.map +1 -0
  193. package/dist/web/render.d.ts +9 -0
  194. package/dist/web/render.d.ts.map +1 -1
  195. package/dist/web/render.js +19 -0
  196. package/dist/web/render.js.map +1 -1
  197. package/dist/web/routes.d.ts.map +1 -1
  198. package/dist/web/routes.js +57 -0
  199. package/dist/web/routes.js.map +1 -1
  200. package/dist/web/well-known.d.ts +21 -0
  201. package/dist/web/well-known.d.ts.map +1 -0
  202. package/dist/web/well-known.js +66 -0
  203. package/dist/web/well-known.js.map +1 -0
  204. package/dist/webmention/fetch-public.d.ts +34 -0
  205. package/dist/webmention/fetch-public.d.ts.map +1 -0
  206. package/dist/webmention/fetch-public.js +101 -0
  207. package/dist/webmention/fetch-public.js.map +1 -0
  208. package/dist/webmention/reply-context.d.ts.map +1 -1
  209. package/dist/webmention/reply-context.js +12 -89
  210. package/dist/webmention/reply-context.js.map +1 -1
  211. package/package.json +1 -1
  212. package/templates/site/content/_data/site.json +7 -1
  213. package/templates/site/content/pages/privacy.md +127 -0
  214. package/themes/default/README.md +58 -19
  215. package/themes/default/layouts/500.njk +8 -0
  216. package/themes/default/layouts/503.njk +8 -0
package/README.md CHANGED
@@ -19,8 +19,8 @@ pnpm install
19
19
  pnpm dev
20
20
  ```
21
21
 
22
- That is a running site on <http://localhost:3000> with a post, a page and the
23
- packaged theme. `geekity init` refuses a directory that already has anything in
22
+ That is a running site on <http://localhost:3000> with a post, an About page, a
23
+ starter privacy page and the packaged theme. `geekity init` refuses a directory that already has anything in
24
24
  it, so it can never write over a site you already have.
25
25
 
26
26
  What it writes:
@@ -32,7 +32,7 @@ server.ts the entry file; where your own routes go
32
32
  tsconfig.json so the site type checks against the package
33
33
  content/
34
34
  posts/ Markdown posts, plus posts.json for Eleventy
35
- pages/ Markdown pages, plus pages.json
35
+ pages/ about.md, privacy.md, plus pages.json
36
36
  _data/site.json the settings: title, tagline, author, page and feed sizes
37
37
  _data/federation/ once the site federates: followers.json and the inbox log
38
38
  _data/comments/ once somebody comments: one JSON file per post
@@ -73,24 +73,33 @@ JSON representation and the content format are all covered by semver: a
73
73
  breaking change to any of them is a major with a migration note in the
74
74
  changelog.
75
75
 
76
+ Upgrading to the release that renamed the session cookie signs everybody out
77
+ of an https site once. See [Session hardening](#session-hardening).
78
+
79
+ Upgrading never deletes a reader's data. The release that added
80
+ [retention](#retention) keeps every commenter email, address hash and contact
81
+ message on a site whose `site.json` has not set a period.
82
+
76
83
  ## The `geekity` command
77
84
 
78
85
  `geekity` is installed as a bin, so `pnpm geekity <command>` runs it inside a
79
86
  site (or `npx geekity`, or a `package.json` script, which is how the generated
80
87
  `sync` script calls it).
81
88
 
82
- | Command | What it does |
83
- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
- | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
85
- | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
86
- | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
87
- | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
88
- | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
89
- | `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
90
- | `geekity --help`, `-h` | The same table, on the terminal. |
91
- | `geekity --version` | The installed version. |
92
-
93
- `serve`, `sync`, `rebuild`, `user add` and `import wordpress-actor` take
89
+ | Command | What it does |
90
+ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `geekity serve` | Boot from the config file and listen. The default when no command is given. |
92
+ | `geekity init <dir>` | Create a new site in `<dir>`. Refuses a directory that is not empty. |
93
+ | `geekity sync` | Rebuild the content index once and exit. Exits non-zero if any file could not be parsed. |
94
+ | `geekity rebuild` | Delete `data/geekity.db` and build it again from the files. |
95
+ | `geekity resend --all`, `<slug>...` | Send announced posts to every follower and relay again, as they now read. See [Quote posts](#quote-posts). |
96
+ | `geekity maintenance on`, `off`, `status` | Take the public site down on purpose with a 503 and `Retry-After`, or bring it back, without a restart. `on --until <time>` names when it should be back. |
97
+ | `geekity user add <name>` | Create an admin account, so a site can get its first login without the setup screen. |
98
+ | `geekity import wordpress-actor <name>` | Bring one person across from the WordPress ActivityPub plugin: their key pair, the actor id their followers hold, the plugin's numeric actor id, and their followers. |
99
+ | `geekity --help`, `-h` | The same table, on the terminal. |
100
+ | `geekity --version` | The installed version. |
101
+
102
+ `serve`, `sync`, `rebuild`, `resend`, `maintenance`, `user add` and `import wordpress-actor` take
94
103
  `--config <file>`; without it they look for `geekity.config.ts`, then
95
104
  `geekity.config.js`, then `geekity.config.mjs` in the working directory, and run
96
105
  on defaults if there is none.
@@ -273,6 +282,8 @@ directory; absolute ones are used as given.
273
282
  | `accessLog` | `false`; `true` under `geekity serve` | `GEEKITY_ACCESS_LOG` | One line per request on stdout: method, path with query, status, duration. See [The access log](#the-access-log). |
274
283
  | `accessLogAddress` | `false` | `GEEKITY_ACCESS_LOG_ADDRESS` | Put the client address on the end of each access-log line. `trustProxy` decides which address that is. |
275
284
  | `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
285
+ | `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
286
+ | `securityHeaders` | [see below](#security-headers) | — | Headers every response carries. A string replaces a default or adds a header, `false` removes one. |
276
287
  | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
277
288
  | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
278
289
  | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
@@ -360,13 +371,14 @@ the same directory reads all of it, and everything in it is meant to be public:
360
371
  | `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
361
372
  | `data/wordpress-activitypub.json` | When each WordPress compatibility path was last asked for. Losing it resets the answer the switch is watched by. |
362
373
 
363
- Two things under `data/` may be deleted whenever the site is stopped, and
374
+ Three things under `data/` may be deleted whenever the site is stopped, and
364
375
  nothing else in either directory may:
365
376
 
366
- | Path | What it is |
367
- | ----------------- | ---------------------------------------------------------------------------------------- |
368
- | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
369
- | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
377
+ | Path | What it is |
378
+ | ----------------- | ----------------------------------------------------------------------------------------------- |
379
+ | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
380
+ | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
381
+ | `data/avatars/` | Remote avatars served from `/_geekity/avatars/` (TASK-134). `rm -r` it; they are fetched again. |
370
382
 
371
383
  The next boot builds the database out of the files with no manual step, and a
372
384
  request for a variant that is not there derives it and serves it.
@@ -538,17 +550,20 @@ its front matter: the record that the post has been announced and when, which
538
550
  is what decides `Create` against `Update` and what a resend reads. Restoring a
539
551
  trashed post reuses it too.
540
552
 
541
- Because that URL is a promise, the editor keeps it: **a published post's slug
542
- and permalink cannot be changed**. Both fields still move freely on a draft,
543
- and a site that really means to move a published post can edit the file,
544
- knowing that its followers will be handed a second object.
545
-
546
553
  A post whose file already names an `activitypub.id` keeps it as its object id
547
554
  for the life of the post — that is how a post migrated from WordPress keeps the
548
555
  `https://example.com/?p=813` its followers, its replies and its RSS subscribers
549
556
  already hold. The CMS serves the post's object at that URL on an ActivityStreams
550
557
  request, redirects a browser from it to the permalink, and names it in every
551
- `Update` and `Delete`. The CMS never writes one itself.
558
+ `Update` and `Delete`.
559
+
560
+ The CMS writes one itself in one case: when a published post's slug or
561
+ permalink is changed in the editor. A fediverse server cannot rename an object
562
+ it holds, so the URL the post is leaving becomes its stored `activitypub.id`.
563
+ Followers get an `Update` of the object they already have, a peer at the old
564
+ URL still gets the object, and a browser there is redirected to the new
565
+ permalink. The object's `url` is the new permalink. See
566
+ [Moved URLs](#moved-urls).
552
567
 
553
568
  One POST serves a whole instance — the shared inbox is preferred — but the
554
569
  outcome is recorded per recipient, so an admin can see which one did not get it
@@ -580,6 +595,56 @@ queue, which retries out of band) or `failed`.
580
595
  The one capability given up is tombstoning a post whose file is gone entirely
581
596
  rather than in the trash: there is no file left to build the `Tombstone` from.
582
597
 
598
+ ### Quote posts
599
+
600
+ Mastodon 4.5 lets people quote a post only when the post says who may, and
601
+ shows a quote only once the quoted author's server has approved it
602
+ ([FEP-044f]). Every post this site federates is public, so every `Note` and
603
+ `Article` carries `interactionPolicy.canQuote` with `automaticApproval` set to
604
+ the public collection: anybody may quote it, and nobody has to approve it by
605
+ hand. Pages, drafts, scheduled and trashed posts do not federate, so nobody
606
+ can quote them.
607
+
608
+ A quoting server sends a `QuoteRequest` to the post's author. The inbox, and
609
+ the WordPress-compatible inbox when that switch is on, answers it:
610
+
611
+ - For a post the site federates, whose quote lives on the requester's own
612
+ server, it stores an approval and replies `Accept` with a
613
+ `QuoteAuthorization` as its `result`. The authorization names the quote
614
+ (`interactingObject`), the post (`interactionTarget`) and the author
615
+ (`attributedTo`).
616
+ - For anything else it replies `Reject` and stores nothing.
617
+
618
+ Approvals are files, like followers:
619
+ `content/_data/federation/{username}/quotes.json` holds one object per quote of
620
+ that user's posts. Each is served unsigned, as ActivityStreams JSON, at
621
+ `{baseUrl}/author/{username}/quotes/{id}/`, for as long as the post is still
622
+ published. An `Undo` of the `QuoteRequest`, or a `Delete` of the quoting post,
623
+ removes the approval, and its URL answers 404 after that. A quote does not
624
+ notify the author.
625
+
626
+ Mastodon keeps the copy of a post it fetched first, so a post federated before
627
+ the quote policy existed is not quotable until its followers receive an
628
+ `Update`. Editing the post sends one. To update every announced post at once,
629
+ run `geekity resend --all` in the site directory:
630
+
631
+ ```sh
632
+ $ geekity resend --all
633
+ hello-world: Update to 3 of 3 inboxes
634
+ another-post: Update to 3 of 3 inboxes
635
+ ```
636
+
637
+ `geekity resend <slug>...` does the same for named posts. Each is what the
638
+ Resend button on the federation screen does: an `Update` for a post still
639
+ published, a `Delete` for one that has been withdrawn, and a `Create` for a
640
+ published post that was never announced. `--all` only reaches posts that were
641
+ announced. The command delivers with no queue, so every activity has been
642
+ posted before it exits, and it exits non-zero when a named slug has nothing to
643
+ resend. It works while the server runs, because both read the same files and
644
+ database.
645
+
646
+ [FEP-044f]: https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
647
+
583
648
  ### Relays
584
649
 
585
650
  A [Mastodon-style relay][fepae0c] boosts every public activity it is sent on to
@@ -1325,6 +1390,109 @@ failure than a spam list to glance at. A checker that is down or throwing is no
1325
1390
  opinion, and the message is stored as it would have been before anybody had
1326
1391
  one.
1327
1392
 
1393
+ ## Personal data
1394
+
1395
+ This is every piece of personal data the CMS stores, where it is, and how long
1396
+ it stays. "Kept" means until somebody deletes it, unless a retention period
1397
+ says otherwise.
1398
+
1399
+ | What | Where | How long |
1400
+ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
1401
+ | A commenter's name, website and words | `content/_data/comments/{slug}.json`, public and in git | Kept. Erasing on request replaces the name and drops the website. |
1402
+ | A commenter's email, and whether they asked to be told about replies | `content/_data/comments/{slug}.json`, **never shown**, but in git | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
1403
+ | A salted hash of the address a comment, webmention or contact message came from (the address itself is never stored) | The comment file, or the contact message file | `addressHashRetentionDays`: forever unless set, 30 on a new site. |
1404
+ | A webmention's author name, website, avatar URL and the source page's words | `content/_data/comments/{slug}.json` | Kept, as a copy of a page that is public already. |
1405
+ | A contact message: the sender's name, email, subject and message | `data/contact/{id}.json`, mode `0600` | `contactMessageRetentionDays`: forever unless set, 365 on a new site. |
1406
+ | The addresses that unsubscribed from reply notices | `data/comment-optouts.json`, mode `0600` | Kept, so the site goes on not writing to them. Erasing on request removes one. |
1407
+ | Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
1408
+ | Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
1409
+ | What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
1410
+ | Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
1411
+ | Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
1412
+ | An index of all of the above, and sessions, reset tokens and spent link tokens | `data/geekity.db` | A cache of the files. Sessions and tokens are pruned when they expire. |
1413
+ | Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
1414
+ | Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
1415
+
1416
+ Two services outside the site see personal data when a site turns them on.
1417
+ Akismet is sent a commenter's or sender's address, user agent, referrer, name,
1418
+ email, website and words. The mail provider is sent every address a message
1419
+ goes to.
1420
+
1421
+ ### The starter privacy page
1422
+
1423
+ `geekity init` writes `content/pages/privacy.md`, served at `/privacy/` and
1424
+ linked from the footer by a `Privacy | /privacy/` line in `menus.footer`. It
1425
+ says in plain language what the table above says, marks the contact form,
1426
+ reply emails and Akismet as optional, and quotes the retention periods a new
1427
+ site starts with. It is a starting point written from what the software does,
1428
+ not legal advice, and a note at its top says so to the owner: review it, add
1429
+ whatever the site does beyond the CMS (analytics, a host's logs, a theme that
1430
+ loads fonts from elsewhere), change the numbers when a retention period
1431
+ changes, and remove the menu line if the page is deleted.
1432
+
1433
+ The link is a menu line rather than something the theme prints, because the
1434
+ footer prints `menus.footer` and nothing of its own (TASK-105). A site that
1435
+ upgrades gets no page and no link; it can copy the file from
1436
+ `templates/site/content/pages/privacy.md` in the package.
1437
+
1438
+ ### Retention
1439
+
1440
+ Three settings in `content/_data/site.json`, on **Settings > Discussion**,
1441
+ decide how long the site keeps what readers hand it. Each counts whole days from
1442
+ when the data arrived, and `0` keeps it forever:
1443
+
1444
+ | Setting | Recommended | What goes when it runs out |
1445
+ | ----------------------------- | ----------- | ----------------------------------------------------------------- |
1446
+ | `commentEmailRetentionDays` | `180` | The email on a comment, and with it the reply subscription. |
1447
+ | `addressHashRetentionDays` | `30` | The address hash on a comment, a webmention or a contact message. |
1448
+ | `contactMessageRetentionDays` | `365` | The whole contact message. |
1449
+
1450
+ **A site keeps everything until its owner sets a period.** A `site.json`
1451
+ without these keys reads as `0` for all three, so an existing site that
1452
+ upgrades loses nothing, and the Discussion screen says "Kept forever" beside
1453
+ each one. A new site from `geekity init` starts with the recommended periods
1454
+ written into its `site.json`. An existing site opts in by typing them on
1455
+ Settings > Discussion and saving once.
1456
+
1457
+ They are settings rather than config because how long a site keeps people's
1458
+ data is the owner's decision, and a privacy notice quotes it; `site.json` is
1459
+ public, which suits a policy. The recommended periods are long enough for a
1460
+ reply notice to arrive, a run of spam to be spotted and a year of
1461
+ correspondence to be answered, and no longer. The first sweep after a site
1462
+ sets a period removes whatever is already past it.
1463
+
1464
+ A sweep runs when the site starts serving and every six hours after that, and
1465
+ `cms.retention.sweep()` runs one now. It reads the files rather than the index,
1466
+ rewrites each comment file and message inside the same lock every other writer
1467
+ takes, and puts each changed comment back into the index in the same step. A
1468
+ second sweep finds nothing left to remove and writes nothing.
1469
+
1470
+ A comment that loses its email keeps its id, its place in the thread, its
1471
+ status, its words and its name. It gains `"redacted": ["email"]`, which is how
1472
+ the index tells an email removed from one never given: a name whose email was
1473
+ removed does not count toward auto-approval, so nobody can type that name and
1474
+ skip the queue. Its author is no longer told about replies, and their next
1475
+ comment waits for a moderator. A moderation link in an inbox names the comment
1476
+ by id, so it goes on working.
1477
+
1478
+ The sweep changes the files as they are now. A comment file in git keeps its
1479
+ history, so an email committed before it was removed is still in the
1480
+ repository's history until that history is rewritten.
1481
+
1482
+ ### Erasing one person's data
1483
+
1484
+ **Tools > Personal data** erases what the site holds for one email address.
1485
+ It shows what it found first: how many comments carry the address, how many
1486
+ contact messages came from it, and whether it is on the opt-out list. Erasing
1487
+ then signs each of those comments `Anonymous`, removes its email, website,
1488
+ avatar and address hash, and marks it `"redacted": ["email", "addressHash",
1489
+ "author"]`; the comment keeps its place in the thread, its status and its words.
1490
+ The contact messages are deleted and the address comes off the opt-out list.
1491
+
1492
+ A comment is found by its email, so one whose email the sweep has already
1493
+ removed, a webmention and a fediverse reply are not found there. The Comments
1494
+ screen deletes those one at a time.
1495
+
1328
1496
  ## Keeping the index in step
1329
1497
 
1330
1498
  Booting scans `contentDir`, indexes every Markdown file under `posts/` and
@@ -1529,7 +1697,7 @@ shadow the login form.
1529
1697
  | `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
1530
1698
  | `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
1531
1699
  | `/admin/login` | Username and password. |
1532
- | `/admin/logout` | `POST` only. Deletes the session row. |
1700
+ | `/admin/logout` | `POST` only. Deletes the session row and sends `Clear-Site-Data`. |
1533
1701
  | `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
1534
1702
 
1535
1703
  The screens behind the login share one layout: a bar across the top with the
@@ -1586,11 +1754,13 @@ Passwords are hashed with argon2id through `node:crypto`, so there is no native
1586
1754
  module to build. The cost parameters travel with each hash, which means raising
1587
1755
  them later leaves every password already stored verifiable.
1588
1756
 
1589
- A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/admin`
1590
- cookie, with `Secure` added when `baseUrl` is an `https` URL. It lasts
1591
- `sessionLifetime`; an expired session is deleted rather than merely ignored.
1592
- Every mutating admin form carries a per-session CSRF token, and a `POST`
1593
- without a valid one is refused with 403 — including the login and setup forms,
1757
+ A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/` cookie.
1758
+ When `baseUrl` is an `https` URL the cookie is `Secure` and named
1759
+ `__Host-geekity_session`; over plain http it is `geekity_session`, as
1760
+ [Session hardening](#session-hardening) explains. It lasts `sessionLifetime`;
1761
+ an expired session is deleted rather than merely ignored. Every mutating admin
1762
+ form carries a per-session CSRF token, and a `POST` without a valid one is
1763
+ refused with 403 — including the login and setup forms,
1594
1764
  which get the token from a short anonymous session created when the form is
1595
1765
  first rendered. Logging in throws that session away and starts a new one, so a
1596
1766
  planted session id cannot become a logged-in one.
@@ -1660,7 +1830,8 @@ The file carries `title`, `tagline`, `url`, `author`, `postsPerPage`,
1660
1830
  `timezone`, `language`, `tagBase`,
1661
1831
  `categoryBase`, `notifyServer`, `webmentionsSend`, `webmentionsReceive`,
1662
1832
  `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
1663
- `contactEmail`, `relays`, `menus` and `taxonomyRedirects`,
1833
+ `contactEmail`, `securityContacts`, `securityPolicy`, `securityLanguages`,
1834
+ `relays`, `menus` and `taxonomyRedirects`,
1664
1835
  and every other key it already had is kept, `feedSize` and anything a site put
1665
1836
  there included. A key it does not carry is the default, and a key of the wrong
1666
1837
  type is the default too: a hand-edited `site.json` cannot take the site down.
@@ -1894,6 +2065,49 @@ header; the leftmost entry is then used. With neither available, which is what
1894
2065
  happens when the app is driven in process rather than served, the username is
1895
2066
  the only key.
1896
2067
 
2068
+ ### Session hardening
2069
+
2070
+ Three things protect a signed-in session beyond the cookie flags and the CSRF
2071
+ token.
2072
+
2073
+ **The cookie is `__Host-geekity_session` under https.** A browser accepts a
2074
+ cookie with the `__Host-` prefix only when it is `Secure`, has `Path=/` and
2075
+ names no `Domain`. That binds it to the exact host, so a page on a sibling
2076
+ subdomain cannot set or overwrite it to plant a session. The prefix needs
2077
+ `Secure`, and a browser drops a `Secure` cookie on a plain-http origin, so a
2078
+ site whose `baseUrl` is `http://localhost:3000` keeps the name
2079
+ `geekity_session` and local development works as before.
2080
+
2081
+ **The upgrade signs people out once.** A session made before this change is in
2082
+ a cookie named `geekity_session`. Under https that name is never accepted as a
2083
+ login again, because accepting it would give a sibling subdomain the way in
2084
+ that the prefix closes. The first admin request that carries only the old
2085
+ cookie deletes its session, expires the old cookie, and sends the browser to
2086
+ the login form with a message that the sign-in cookie was renamed and asks the
2087
+ person to sign in again. On the public site the old cookie is ignored, so a
2088
+ signed-in commenter sees the stranger's form until they sign in again.
2089
+
2090
+ **Fetch Metadata refuses cross-site writes.** A browser sends
2091
+ `Sec-Fetch-Site` with every request, and a page cannot change it. Every
2092
+ request under `/admin` other than `GET`, `HEAD` and `OPTIONS`, including the
2093
+ login and setup forms, is refused with 403 when that header says `cross-site`
2094
+ or `same-site`, before the session is read or a handler runs. A comment posted
2095
+ by somebody signed in gets the same check. `same-origin` and `none` pass. A
2096
+ request with no `Sec-Fetch-Site`, from an older browser or a script, falls back
2097
+ to the CSRF token, which every such request still has to carry. Nothing else
2098
+ is checked: the ActivityPub inboxes, the WordPress-compatible inbox,
2099
+ webmentions, the contact form, a stranger's comment, and the one-click
2100
+ moderate and unsubscribe links in an email take cross-site `POST`s by design,
2101
+ and they act on no session.
2102
+
2103
+ **Logout clears the site's data in the browser.** The logout response sends
2104
+ `Clear-Site-Data: "cache", "cookies", "storage"`, so a shared computer does not
2105
+ keep admin pages in its cache or anything the site stored. Browsers honour it
2106
+ only on a secure origin, which includes `http://localhost`. The `cookies`
2107
+ directive clears cookies for the whole registrable domain, so signing out of
2108
+ `blog.example.com` also signs the browser out of other sites under
2109
+ `example.com`.
2110
+
1897
2111
  ### Forgotten passwords
1898
2112
 
1899
2113
  `/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
@@ -1937,12 +2151,67 @@ no restart.
1937
2151
  Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
1938
2152
  `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
1939
2153
  is an `https` URL — the same test that decides whether the session cookie is
1940
- `Secure`, so the two cannot disagree. That is all the public site gets: a theme
1941
- is somebody else's HTML and the CMS has no business deciding what it may
1942
- reference.
2154
+ `Secure`, so the two cannot disagree. Neither can be turned off.
2155
+
2156
+ Every response also carries these, public pages, feeds, uploads, ActivityPub
2157
+ JSON, redirects, 404s, the maintenance 503 and the 500 page alike:
2158
+
2159
+ | Header | Default |
2160
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
2161
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` |
2162
+ | `Content-Security-Policy` | `frame-ancestors 'self'` |
2163
+ | `X-Frame-Options` | `SAMEORIGIN` |
2164
+ | `Permissions-Policy` | `browsing-topics=(), camera=(), display-capture=(), geolocation=(), hid=(), microphone=(), midi=(), payment=(), serial=(), usb=(), xr-spatial-tracking=()` |
2165
+ | `Cross-Origin-Opener-Policy` | `same-origin` |
2166
+
2167
+ None of them limits what a page may load, and that is the line the CMS holds
2168
+ on the public site. There is no content policy, no `default-src`, `script-src`
2169
+ or `img-src`, because a theme is somebody else's HTML and the CMS has no
2170
+ business deciding what it may reference. `frame-ancestors` is the only
2171
+ directive in the policy, and it is about who may put the page in a frame, not
2172
+ what is in it. Each header does something else:
2173
+
2174
+ - `Referrer-Policy` sends only the origin, not the full URL, when a reader
2175
+ follows a link to another site, so the path and query of the page they were
2176
+ on stay on the site.
2177
+ - `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` stop another site
2178
+ from framing a page. That matters most for a signed-in user, whose pages carry
2179
+ a comment form that posts as them, but it covers every page because the CMS
2180
+ cannot tell a framed reader from a framed user.
2181
+ - `Permissions-Policy` turns off browser features a blog has no use for. It
2182
+ leaves alone what an embedded video player asks for: autoplay, fullscreen,
2183
+ `encrypted-media`, picture-in-picture, and the motion sensors behind a
2184
+ 360-degree video.
2185
+ - `Cross-Origin-Opener-Policy: same-origin` keeps a window the site opens, or a
2186
+ window that opens the site, from reaching into it through `window.opener`.
2187
+
2188
+ Two of them can get in the way of something a site does on purpose. A site that
2189
+ is meant to be framed elsewhere, such as in a portfolio or a slide deck, has to
2190
+ name that host in `frame-ancestors` and remove `X-Frame-Options`, which cannot
2191
+ name another host. A theme that signs readers in or takes payment through a
2192
+ popup on another origin needs `same-origin-allow-popups` for COOP. The CMS
2193
+ itself does neither.
2194
+
2195
+ A site changes them with `securityHeaders` in its config. Names are matched
2196
+ without regard to case. A string replaces a default or adds a header of the
2197
+ site's own, and `false` removes a default:
2198
+
2199
+ ```ts
2200
+ export default defineConfig({
2201
+ securityHeaders: {
2202
+ 'Content-Security-Policy':
2203
+ "frame-ancestors 'self' https://portfolio.example",
2204
+ 'X-Frame-Options': false,
2205
+ 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
2206
+ },
2207
+ });
2208
+ ```
2209
+
2210
+ A name that is not a header name, or a value that is empty or has a line break
2211
+ in it, stops the site at boot. There is no environment variable for these.
1943
2212
 
1944
- Every response under `/admin`, static files and redirects included, carries
1945
- three more:
2213
+ Every response under `/admin`, static files and redirects included, gets three
2214
+ stricter values in their place, whatever `securityHeaders` says:
1946
2215
 
1947
2216
  | Header | Value |
1948
2217
  | ------------------------- | --------------------------------------------- |
@@ -1971,35 +2240,69 @@ uploads. Four directives say more than that:
1971
2240
  sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
1972
2241
  the admin page itself.
1973
2242
 
2243
+ ### security.txt and change-password
2244
+
2245
+ `/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))
2246
+ tells somebody who finds a vulnerability in the site where to report it. It is
2247
+ built from three settings on the Email settings page, under Security contact,
2248
+ which `content/_data/site.json` holds:
2249
+
2250
+ | Setting | `site.json` key | Default | What it becomes |
2251
+ | ------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
2252
+ | Contacts | `securityContacts` | `[]` | One `Contact:` line each, in order. An email address, stored as `mailto:…`, an `https://` URL or a `tel:` number. |
2253
+ | Disclosure policy | `securityPolicy` | `""` | A `Policy:` line. An `https://` URL, or empty for no line. |
2254
+ | Preferred languages | `securityLanguages` | `""` | A `Preferred-Languages:` line. Language tags separated by commas, or empty for no line. |
2255
+
2256
+ With no contact the path is a 404: the RFC requires at least one. The
2257
+ `contactEmail` setting is never used in its place, because that address is
2258
+ promised never to appear on the public site.
2259
+
2260
+ The file is built on every request and served as `text/plain; charset=utf-8`.
2261
+ `Expires` is 30 days after the request, so it never goes stale however long the
2262
+ site runs, and `Canonical` is the path under the base URL in effect. There is no
2263
+ config-file key or environment variable for any of these: like `contactEmail`,
2264
+ they are site settings a person edits on the settings screen.
2265
+
2266
+ `/.well-known/change-password` is where a password manager sends somebody who
2267
+ wants to change a saved password. It redirects with a `302` and
2268
+ `Cache-Control: no-store`: to `/admin/users/{id}#change-password`, the form on
2269
+ the signed-in user's own page, or to the login form when nobody is signed in.
2270
+
2271
+ Both paths answer as usual in maintenance mode. A researcher may need the
2272
+ security contact most while the site is down, and change-password only points
2273
+ into the admin, which maintenance mode leaves open.
2274
+
1974
2275
  ## The public site
1975
2276
 
1976
2277
  Booting mounts the public site on the app. The routes are:
1977
2278
 
1978
- | Route | What it serves |
1979
- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
1980
- | `/` | Published posts, newest first. |
1981
- | `/page/2/` and up | Later pages of the same archive. |
1982
- | a document's permalink | The post or the page, through the theme. |
1983
- | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
1984
- | `/category/{name}/` | The second taxonomy, paginated the same way. |
1985
- | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
1986
- | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
1987
- | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
1988
- | `/author/{username}/feed/` and siblings | The same, for one person. |
1989
- | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
1990
- | `{permalink}feed/` | One post's replies, the same way. |
1991
- | `/sitemap.xml` | Every public URL, for a search engine. |
1992
- | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
1993
- | `/robots.txt` | What a crawler may have, and where the sitemap is. |
1994
- | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
1995
- | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
1996
- | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
1997
- | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
1998
- | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
1999
- | `/theme/…` | The theme's own files, from its `static/` directory. |
2000
- | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
2001
- | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
2002
- | anything else | The theme's 404. |
2279
+ | Route | What it serves |
2280
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
2281
+ | `/` | Published posts, newest first. |
2282
+ | `/page/2/` and up | Later pages of the same archive. |
2283
+ | a document's permalink | The post or the page, through the theme. |
2284
+ | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
2285
+ | `/category/{name}/` | The second taxonomy, paginated the same way. |
2286
+ | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
2287
+ | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
2288
+ | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
2289
+ | `/author/{username}/feed/` and siblings | The same, for one person. |
2290
+ | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
2291
+ | `{permalink}feed/` | One post's replies, the same way. |
2292
+ | `/sitemap.xml` | Every public URL, for a search engine. |
2293
+ | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
2294
+ | `/robots.txt` | What a crawler may have, and where the sitemap is. |
2295
+ | `/.well-known/security.txt` | Where to report a vulnerability; a 404 until a security contact is set. See [above](#securitytxt-and-change-password). |
2296
+ | `/.well-known/change-password` | A redirect to the signed-in user's change-password form, or to the login form. |
2297
+ | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
2298
+ | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
2299
+ | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
2300
+ | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
2301
+ | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
2302
+ | `/theme/…` | The theme's own files, from its `static/` directory. |
2303
+ | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
2304
+ | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
2305
+ | anything else | The theme's 404. |
2003
2306
 
2004
2307
  The federation routes go on before it, and answer only their own paths:
2005
2308
 
@@ -2146,6 +2449,85 @@ hand-edited `site.json` that is not an object of lists of items yields no menu
2146
2449
  rather than an error, exactly as a bad archive base falls back rather than
2147
2450
  taking the site down.
2148
2451
 
2452
+ ### Moved URLs
2453
+
2454
+ Changing the slug or the permalink of a published post or page in the editor
2455
+ moves it, and the URL it leaves is written into its file:
2456
+
2457
+ ```yaml
2458
+ permalink: /2026/01/new-name/
2459
+ redirect_from:
2460
+ - /2026/01/first-name/
2461
+ - /2026/01/second-name/
2462
+ ```
2463
+
2464
+ Every URL in `redirect_from` answers `301 Moved Permanently` with the current
2465
+ permalink, and so do its `.md` and `.json` spellings. Each entry points at the
2466
+ document itself, not at the next rename, so no chain of redirects builds up.
2467
+ The list is in the file, so deleting `data/geekity.db` keeps it working.
2468
+
2469
+ A URL in the list only redirects while nothing else lives there: a new document
2470
+ given that URL takes it over. Moving a document back to a URL in its list takes
2471
+ that URL off the list. A draft that was never published moves without leaving
2472
+ anything behind. Correcting a published post's date files it under the new
2473
+ day but keeps its URL.
2474
+
2475
+ ### Declared redirects
2476
+
2477
+ A site that moves to this CMS, or reorganises, lists the old URLs that no
2478
+ document owns in `content/_data/redirects.json`. The file is a JSON list:
2479
+
2480
+ ```json
2481
+ [
2482
+ { "from": "/?p=123", "to": "/2026/01/hello/" },
2483
+ { "from": "/old-section/", "to": "/new-section/", "status": 301 },
2484
+ { "from": "/sale/", "to": "/shop/", "status": 302 },
2485
+ { "from": "/forum/", "to": "https://forum.example.com/", "status": 308 }
2486
+ ]
2487
+ ```
2488
+
2489
+ - `from` is a path on this site. It starts with `/` and can carry a query
2490
+ string.
2491
+ - `to` is a path on this site, or an absolute `http` or `https` URL.
2492
+ - `status` is `301` or `308` for a permanent redirect, and `302` or `307` for a
2493
+ temporary one. It defaults to `301`.
2494
+
2495
+ A `from` without a query string matches that path with any query, or none. The
2496
+ request's query goes on to a `to` that has no query of its own, so
2497
+ `/old-section/?page=2` lands on `/new-section/?page=2`. The path must match
2498
+ exactly, except that a `from` ending in `/` also answers the same path without
2499
+ the slash, in one hop.
2500
+
2501
+ A `from` with a query string matches only that query. The order of the
2502
+ parameters does not matter, so `/archives/?cat=4&paged=2` also matches
2503
+ `/archives/?paged=2&cat=4`. Nothing else in the request's query may differ:
2504
+ `/?p=123` does not match `/?p=123&replytocom=9`. This is how old WordPress
2505
+ `?p=` links reach their posts.
2506
+
2507
+ A declared redirect never hides something real. A path-only `from` is answered
2508
+ only after every document, archive, feed and moved URL has had its turn, and
2509
+ just before the 404 page, so a document given that URL later takes it over. A
2510
+ `from` with a query string is answered first, because no document is addressed
2511
+ by its query. The admin, the federation endpoints and the health check answer
2512
+ before any declared redirect.
2513
+
2514
+ The file is read on the next request after it changes, with no restart. An
2515
+ entry that cannot be served is logged as a warning at boot, and again on the
2516
+ first request after the file changes, and it is skipped. The following entries are skipped:
2517
+
2518
+ - An entry with a missing or malformed `from`, `to` or `status`.
2519
+ - A second entry with the same `from`. The first entry wins.
2520
+ - Every entry that leads into a loop, such as `/a/` to `/b/` and `/b/` back to
2521
+ `/a/`.
2522
+
2523
+ A file that is not a JSON list serves no redirects and is reported the same
2524
+ way.
2525
+
2526
+ Every redirect the CMS sends carries `X-Redirect-By: Geekity CMS`. This
2527
+ includes the declared redirects, moved URLs, trailing slashes, feed spellings
2528
+ and the admin's redirects. Someone who traces a chain of redirects through a
2529
+ proxy can see which layer sent each one.
2530
+
2149
2531
  ## Content negotiation
2150
2532
 
2151
2533
  Every public URL is one resource with more than one body. A post or a page has