@geekity/cms 0.8.0 → 0.10.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 (236) hide show
  1. package/README.md +344 -47
  2. package/admin/components/admin-bar.njk +26 -0
  3. package/admin/components/public-admin-bar.njk +41 -0
  4. package/admin/layouts/base.njk +1 -0
  5. package/admin/layouts/shell.njk +8 -19
  6. package/admin/pages/comments/all.njk +3 -0
  7. package/admin/pages/settings/discussion.njk +53 -0
  8. package/admin/pages/settings/email.njk +20 -0
  9. package/admin/pages/tools/personal-data.njk +67 -0
  10. package/admin/pages/users/edit.njk +1 -1
  11. package/admin/static/admin-bar.css +137 -0
  12. package/admin/static/admin.css +1 -102
  13. package/dist/admin/comments.d.ts +5 -0
  14. package/dist/admin/comments.d.ts.map +1 -1
  15. package/dist/admin/comments.js +15 -1
  16. package/dist/admin/comments.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 +2 -2
  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 +94 -0
  31. package/dist/admin/personal-data.js.map +1 -0
  32. package/dist/admin/preview.d.ts.map +1 -1
  33. package/dist/admin/preview.js +1 -1
  34. package/dist/admin/preview.js.map +1 -1
  35. package/dist/admin/routes.d.ts.map +1 -1
  36. package/dist/admin/routes.js +20 -3
  37. package/dist/admin/routes.js.map +1 -1
  38. package/dist/admin/session.d.ts +42 -1
  39. package/dist/admin/session.d.ts.map +1 -1
  40. package/dist/admin/session.js +74 -4
  41. package/dist/admin/session.js.map +1 -1
  42. package/dist/admin/settings-discussion.d.ts.map +1 -1
  43. package/dist/admin/settings-discussion.js +9 -1
  44. package/dist/admin/settings-discussion.js.map +1 -1
  45. package/dist/admin/settings-email.d.ts.map +1 -1
  46. package/dist/admin/settings-email.js +10 -1
  47. package/dist/admin/settings-email.js.map +1 -1
  48. package/dist/admin/settings.d.ts +39 -0
  49. package/dist/admin/settings.d.ts.map +1 -1
  50. package/dist/admin/settings.js +144 -0
  51. package/dist/admin/settings.js.map +1 -1
  52. package/dist/admin/store.d.ts +65 -0
  53. package/dist/admin/store.d.ts.map +1 -1
  54. package/dist/admin/store.js +130 -5
  55. package/dist/admin/store.js.map +1 -1
  56. package/dist/admin/templates.d.ts +2 -0
  57. package/dist/admin/templates.d.ts.map +1 -1
  58. package/dist/admin/templates.js +2 -0
  59. package/dist/admin/templates.js.map +1 -1
  60. package/dist/admin/tools.d.ts +2 -0
  61. package/dist/admin/tools.d.ts.map +1 -1
  62. package/dist/admin/tools.js +3 -2
  63. package/dist/admin/tools.js.map +1 -1
  64. package/dist/avatars/avatars.d.ts +112 -0
  65. package/dist/avatars/avatars.d.ts.map +1 -0
  66. package/dist/avatars/avatars.js +259 -0
  67. package/dist/avatars/avatars.js.map +1 -0
  68. package/dist/avatars/index.d.ts +4 -0
  69. package/dist/avatars/index.d.ts.map +1 -0
  70. package/dist/avatars/index.js +3 -0
  71. package/dist/avatars/index.js.map +1 -0
  72. package/dist/avatars/routes.d.ts +11 -0
  73. package/dist/avatars/routes.d.ts.map +1 -0
  74. package/dist/avatars/routes.js +33 -0
  75. package/dist/avatars/routes.js.map +1 -0
  76. package/dist/comments/index.d.ts +2 -2
  77. package/dist/comments/index.d.ts.map +1 -1
  78. package/dist/comments/index.js +1 -1
  79. package/dist/comments/index.js.map +1 -1
  80. package/dist/comments/records.d.ts +58 -2
  81. package/dist/comments/records.d.ts.map +1 -1
  82. package/dist/comments/records.js +244 -47
  83. package/dist/comments/records.js.map +1 -1
  84. package/dist/comments/routes.d.ts.map +1 -1
  85. package/dist/comments/routes.js +8 -2
  86. package/dist/comments/routes.js.map +1 -1
  87. package/dist/comments/viewer.d.ts +13 -0
  88. package/dist/comments/viewer.d.ts.map +1 -1
  89. package/dist/comments/viewer.js +13 -1
  90. package/dist/comments/viewer.js.map +1 -1
  91. package/dist/config.d.ts +35 -0
  92. package/dist/config.d.ts.map +1 -1
  93. package/dist/config.js +58 -0
  94. package/dist/config.js.map +1 -1
  95. package/dist/contact/index.d.ts +1 -1
  96. package/dist/contact/index.d.ts.map +1 -1
  97. package/dist/contact/index.js +1 -1
  98. package/dist/contact/index.js.map +1 -1
  99. package/dist/contact/records.d.ts +8 -2
  100. package/dist/contact/records.d.ts.map +1 -1
  101. package/dist/contact/records.js +13 -6
  102. package/dist/contact/records.js.map +1 -1
  103. package/dist/env.d.ts +25 -0
  104. package/dist/env.d.ts.map +1 -1
  105. package/dist/federation/delivery.d.ts +3 -1
  106. package/dist/federation/delivery.d.ts.map +1 -1
  107. package/dist/federation/delivery.js +6 -1
  108. package/dist/federation/delivery.js.map +1 -1
  109. package/dist/federation/federation.d.ts +3 -0
  110. package/dist/federation/federation.d.ts.map +1 -1
  111. package/dist/federation/federation.js.map +1 -1
  112. package/dist/federation/inbox.d.ts +7 -5
  113. package/dist/federation/inbox.d.ts.map +1 -1
  114. package/dist/federation/inbox.js +15 -32
  115. package/dist/federation/inbox.js.map +1 -1
  116. package/dist/federation/index.d.ts +2 -0
  117. package/dist/federation/index.d.ts.map +1 -1
  118. package/dist/federation/index.js +1 -0
  119. package/dist/federation/index.js.map +1 -1
  120. package/dist/federation/mount.js +6 -1
  121. package/dist/federation/mount.js.map +1 -1
  122. package/dist/federation/profiles.d.ts +95 -0
  123. package/dist/federation/profiles.d.ts.map +1 -0
  124. package/dist/federation/profiles.js +171 -0
  125. package/dist/federation/profiles.js.map +1 -0
  126. package/dist/federation/quotes.d.ts +2 -0
  127. package/dist/federation/quotes.d.ts.map +1 -1
  128. package/dist/federation/quotes.js +4 -0
  129. package/dist/federation/quotes.js.map +1 -1
  130. package/dist/federation/relays.d.ts +3 -1
  131. package/dist/federation/relays.d.ts.map +1 -1
  132. package/dist/federation/relays.js +6 -1
  133. package/dist/federation/relays.js.map +1 -1
  134. package/dist/federation/replies.d.ts +36 -4
  135. package/dist/federation/replies.d.ts.map +1 -1
  136. package/dist/federation/replies.js +71 -15
  137. package/dist/federation/replies.js.map +1 -1
  138. package/dist/images/icons.d.ts.map +1 -1
  139. package/dist/images/icons.js +3 -2
  140. package/dist/images/icons.js.map +1 -1
  141. package/dist/images/index.d.ts +2 -2
  142. package/dist/images/index.d.ts.map +1 -1
  143. package/dist/images/markup.d.ts +15 -4
  144. package/dist/images/markup.d.ts.map +1 -1
  145. package/dist/images/markup.js +24 -15
  146. package/dist/images/markup.js.map +1 -1
  147. package/dist/images/paths.d.ts +11 -0
  148. package/dist/images/paths.d.ts.map +1 -1
  149. package/dist/images/paths.js +17 -1
  150. package/dist/images/paths.js.map +1 -1
  151. package/dist/images/variants.d.ts +13 -3
  152. package/dist/images/variants.d.ts.map +1 -1
  153. package/dist/images/variants.js +13 -11
  154. package/dist/images/variants.js.map +1 -1
  155. package/dist/index.d.ts +34 -7
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +91 -18
  158. package/dist/index.js.map +1 -1
  159. package/dist/notifications/index.d.ts +1 -1
  160. package/dist/notifications/index.d.ts.map +1 -1
  161. package/dist/notifications/index.js +1 -1
  162. package/dist/notifications/index.js.map +1 -1
  163. package/dist/notifications/optouts.d.ts +6 -0
  164. package/dist/notifications/optouts.d.ts.map +1 -1
  165. package/dist/notifications/optouts.js +17 -0
  166. package/dist/notifications/optouts.js.map +1 -1
  167. package/dist/notifications/routes.js +1 -1
  168. package/dist/notifications/routes.js.map +1 -1
  169. package/dist/privacy/erase.d.ts +45 -0
  170. package/dist/privacy/erase.d.ts.map +1 -0
  171. package/dist/privacy/erase.js +76 -0
  172. package/dist/privacy/erase.js.map +1 -0
  173. package/dist/privacy/policy.d.ts +32 -0
  174. package/dist/privacy/policy.d.ts.map +1 -0
  175. package/dist/privacy/policy.js +37 -0
  176. package/dist/privacy/policy.js.map +1 -0
  177. package/dist/privacy/retention.d.ts +79 -0
  178. package/dist/privacy/retention.d.ts.map +1 -0
  179. package/dist/privacy/retention.js +133 -0
  180. package/dist/privacy/retention.js.map +1 -0
  181. package/dist/web/admin-bar.d.ts +21 -0
  182. package/dist/web/admin-bar.d.ts.map +1 -0
  183. package/dist/web/admin-bar.js +145 -0
  184. package/dist/web/admin-bar.js.map +1 -0
  185. package/dist/web/assets.d.ts +29 -1
  186. package/dist/web/assets.d.ts.map +1 -1
  187. package/dist/web/assets.js +48 -3
  188. package/dist/web/assets.js.map +1 -1
  189. package/dist/web/compression.d.ts +19 -0
  190. package/dist/web/compression.d.ts.map +1 -0
  191. package/dist/web/compression.js +160 -0
  192. package/dist/web/compression.js.map +1 -0
  193. package/dist/web/context.d.ts +5 -1
  194. package/dist/web/context.d.ts.map +1 -1
  195. package/dist/web/context.js +5 -2
  196. package/dist/web/context.js.map +1 -1
  197. package/dist/web/conversation.d.ts +9 -1
  198. package/dist/web/conversation.d.ts.map +1 -1
  199. package/dist/web/conversation.js +79 -17
  200. package/dist/web/conversation.js.map +1 -1
  201. package/dist/web/index.d.ts +1 -0
  202. package/dist/web/index.d.ts.map +1 -1
  203. package/dist/web/index.js +1 -0
  204. package/dist/web/index.js.map +1 -1
  205. package/dist/web/maintenance.d.ts.map +1 -1
  206. package/dist/web/maintenance.js +7 -2
  207. package/dist/web/maintenance.js.map +1 -1
  208. package/dist/web/render.d.ts +27 -0
  209. package/dist/web/render.d.ts.map +1 -1
  210. package/dist/web/render.js +77 -38
  211. package/dist/web/render.js.map +1 -1
  212. package/dist/web/routes.d.ts.map +1 -1
  213. package/dist/web/routes.js +67 -28
  214. package/dist/web/routes.js.map +1 -1
  215. package/dist/web/templates.d.ts.map +1 -1
  216. package/dist/web/templates.js +9 -2
  217. package/dist/web/templates.js.map +1 -1
  218. package/dist/web/well-known.d.ts +21 -0
  219. package/dist/web/well-known.d.ts.map +1 -0
  220. package/dist/web/well-known.js +66 -0
  221. package/dist/web/well-known.js.map +1 -0
  222. package/dist/webmention/fetch-public.d.ts +34 -0
  223. package/dist/webmention/fetch-public.d.ts.map +1 -0
  224. package/dist/webmention/fetch-public.js +101 -0
  225. package/dist/webmention/fetch-public.js.map +1 -0
  226. package/dist/webmention/reply-context.d.ts.map +1 -1
  227. package/dist/webmention/reply-context.js +12 -89
  228. package/dist/webmention/reply-context.js.map +1 -1
  229. package/dist/webmention/service.d.ts.map +1 -1
  230. package/dist/webmention/service.js +5 -1
  231. package/dist/webmention/service.js.map +1 -1
  232. package/package.json +1 -1
  233. package/templates/site/content/_data/site.json +7 -1
  234. package/templates/site/content/pages/privacy.md +127 -0
  235. package/themes/default/README.md +165 -22
  236. package/themes/default/layouts/base.njk +51 -10
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,6 +73,13 @@ 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
@@ -276,6 +283,8 @@ directory; absolute ones are used as given.
276
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. |
277
284
  | `accessLogWriter` | stdout | — | Where the lines go instead. See [The access log](#the-access-log). |
278
285
  | `maintenance` | `false` | `GEEKITY_MAINTENANCE` | Keep the site in maintenance mode, answering 503, until a restart without it. |
286
+ | `compression` | `true` | `GEEKITY_COMPRESSION` | Compress text responses with brotli or gzip. Turn it off behind a proxy that compresses. See [Compression](#compression). |
287
+ | `securityHeaders` | [see below](#security-headers) | — | Headers every response carries. A string replaces a default or adds a header, `false` removes one. |
279
288
  | `onDocumentChange` | none | — | Hook run for every change to the index. See [Hooks](#hooks). |
280
289
  | `onPublish` | none | — | Hook run when a document becomes visible. See [Hooks](#hooks). |
281
290
  | `federation` | `{}` | — | Federation stores and guards. See [Federation](#federation). |
@@ -356,6 +365,7 @@ the same directory reads all of it, and everything in it is meant to be public:
356
365
  | `data/users.json` | Usernames and argon2id password hashes. Mode `0600`. |
357
366
  | `data/keys/` | Each user's actor key pairs as JWK files. Mode `0600`. **Losing these breaks federation.** |
358
367
  | `data/comment-salt` | What hides commenters' addresses in the published comment files. Mode `0600`. |
368
+ | `data/comments/{slug}.json` | The emails of that post's commenters, and whether each asked to hear about replies, keyed by comment id. Mode `0600`. |
359
369
  | `data/akismet.json` | The Akismet key, and what `verify-key` last said about it. Mode `0600`. |
360
370
  | `data/mail.json` | The mail credential: a Brevo API key, an SMTP connection, or both. Mode `0600`. |
361
371
  | `data/notification-secret` | What signs the one-click links in a notification. Mode `0600`. Losing it kills every link already in an inbox and nothing else. |
@@ -363,13 +373,14 @@ the same directory reads all of it, and everything in it is meant to be public:
363
373
  | `data/notification-digests.json` | When each user was last sent a digest. Mode `0600`. Losing it sends one digest early and nothing worse. |
364
374
  | `data/wordpress-activitypub.json` | When each WordPress compatibility path was last asked for. Losing it resets the answer the switch is watched by. |
365
375
 
366
- Two things under `data/` may be deleted whenever the site is stopped, and
376
+ Three things under `data/` may be deleted whenever the site is stopped, and
367
377
  nothing else in either directory may:
368
378
 
369
- | Path | What it is |
370
- | ----------------- | ---------------------------------------------------------------------------------------- |
371
- | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
372
- | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
379
+ | Path | What it is |
380
+ | ----------------- | ----------------------------------------------------------------------------------------------- |
381
+ | `data/geekity.db` | The SQLite cache, with its `-wal` and `-shm`. `geekity rebuild` deletes and rebuilds it. |
382
+ | `data/images/` | Variants derived from `content/uploads/`, with their `image.json` sidecars. `rm -r` it. |
383
+ | `data/avatars/` | Remote avatars served from `/_geekity/avatars/` (TASK-134). `rm -r` it; they are fetched again. |
373
384
 
374
385
  The next boot builds the database out of the files with no manual step, and a
375
386
  request for a variant that is not there derives it and serves it.
@@ -970,7 +981,12 @@ comments — id, source, kind, status, author, the Markdown and the HTML it
970
981
  rendered to, when it was submitted, a salted hash of the address, and what it
971
982
  answers — so they are in git beside the posts, an Eleventy build of the same
972
983
  directory shows them, and the `comments` table is an index emptied and read
973
- back on every boot.
984
+ back on every boot. The commenter's email is not in that file: it is in
985
+ `data/comments/{slug}.json` (mode `0600`), keyed by comment id, because
986
+ everything under `content/` is published and goes into git. A site upgrading
987
+ from a version that kept the email in the comment file has it moved there on
988
+ the next boot, and so does an email somebody types into a comment file by
989
+ hand.
974
990
 
975
991
  Three settings and one front-matter key decide whether a post is still taking
976
992
  them, and they are read in one place so the form and the endpoint can never
@@ -1031,7 +1047,7 @@ on one comment, lasts a week, and is spent the first time it is used.
1031
1047
 
1032
1048
  Commenters get the other half. The form offers "email me when somebody replies
1033
1049
  to this", but only on a site that can actually send mail; ticking it stores
1034
- `notify: true` beside the address that is already in the comment file, and
1050
+ `notify: true` beside the commenter's address in `data/comments/{slug}.json`, and
1035
1051
  nothing about either is ever rendered — not on the page, not in the JSON or
1036
1052
  Markdown representations, and not in the comments feeds. When a reply to that
1037
1053
  comment is **approved**, one message goes out with the reply in it and an
@@ -1381,6 +1397,112 @@ failure than a spam list to glance at. A checker that is down or throwing is no
1381
1397
  opinion, and the message is stored as it would have been before anybody had
1382
1398
  one.
1383
1399
 
1400
+ ## Personal data
1401
+
1402
+ This is every piece of personal data the CMS stores, where it is, and how long
1403
+ it stays. "Kept" means until somebody deletes it, unless a retention period
1404
+ says otherwise.
1405
+
1406
+ | What | Where | How long |
1407
+ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------ |
1408
+ | 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. |
1409
+ | A commenter's email, and whether they asked to be told about replies | `data/comments/{slug}.json`, mode `0600`, keyed by comment id | `commentEmailRetentionDays`: forever unless set, 180 on a new site. |
1410
+ | 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. |
1411
+ | 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. |
1412
+ | 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. |
1413
+ | 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. |
1414
+ | Followers: actor id, handle, display name, avatar URL, profile URL | `content/_data/federation/{username}/followers.json`, in git | Until they unfollow. |
1415
+ | Inbound likes, boosts, replies and quotes, with the actor who sent them | `content/_data/federation/inbox/{yyyy}-{mm}.jsonl`, in git | Kept. |
1416
+ | What a reply shows of the post it answers: its title, words and author | `content/_data/replyContexts.json`, in git | Kept. |
1417
+ | Other actors who liked, boosted, answered or quoted: handle, display name, avatar URL, profile URL | `data/geekity.db`, fetched from their actor document | A cache. Refetched weekly while the inbox log names them. |
1418
+ | Remote avatars, shrunk | `data/avatars/` | Deleted by the avatar sweep once nothing shown names them. |
1419
+ | Users: username, email, argon2id password hash, profile | `data/users.json`, mode `0600` | Until the user is deleted. |
1420
+ | 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. |
1421
+ | Client addresses in the rate limits | Memory | Until the window passes or the site restarts. |
1422
+ | Client addresses in the access log | stdout, and whatever collects it | Only with `accessLogAddress` on. The collector keeps them. |
1423
+
1424
+ Two services outside the site see personal data when a site turns them on.
1425
+ Akismet is sent a commenter's or sender's address, user agent, referrer, name,
1426
+ email, website and words. The mail provider is sent every address a message
1427
+ goes to.
1428
+
1429
+ ### The starter privacy page
1430
+
1431
+ `geekity init` writes `content/pages/privacy.md`, served at `/privacy/` and
1432
+ linked from the footer by a `Privacy | /privacy/` line in `menus.footer`. It
1433
+ says in plain language what the table above says, marks the contact form,
1434
+ reply emails and Akismet as optional, and quotes the retention periods a new
1435
+ site starts with. It is a starting point written from what the software does,
1436
+ not legal advice, and a note at its top says so to the owner: review it, add
1437
+ whatever the site does beyond the CMS (analytics, a host's logs, a theme that
1438
+ loads fonts from elsewhere), change the numbers when a retention period
1439
+ changes, and remove the menu line if the page is deleted.
1440
+
1441
+ The link is a menu line rather than something the theme prints, because the
1442
+ footer prints `menus.footer` and nothing of its own (TASK-105). A site that
1443
+ upgrades gets no page and no link; it can copy the file from
1444
+ `templates/site/content/pages/privacy.md` in the package.
1445
+
1446
+ ### Retention
1447
+
1448
+ Three settings in `content/_data/site.json`, on **Settings > Discussion**,
1449
+ decide how long the site keeps what readers hand it. Each counts whole days from
1450
+ when the data arrived, and `0` keeps it forever:
1451
+
1452
+ | Setting | Recommended | What goes when it runs out |
1453
+ | ----------------------------- | ----------- | ----------------------------------------------------------------- |
1454
+ | `commentEmailRetentionDays` | `180` | The email on a comment, and with it the reply subscription. |
1455
+ | `addressHashRetentionDays` | `30` | The address hash on a comment, a webmention or a contact message. |
1456
+ | `contactMessageRetentionDays` | `365` | The whole contact message. |
1457
+
1458
+ **A site keeps everything until its owner sets a period.** A `site.json`
1459
+ without these keys reads as `0` for all three, so an existing site that
1460
+ upgrades loses nothing, and the Discussion screen says "Kept forever" beside
1461
+ each one. A new site from `geekity init` starts with the recommended periods
1462
+ written into its `site.json`. An existing site opts in by typing them on
1463
+ Settings > Discussion and saving once.
1464
+
1465
+ They are settings rather than config because how long a site keeps people's
1466
+ data is the owner's decision, and a privacy notice quotes it; `site.json` is
1467
+ public, which suits a policy. The recommended periods are long enough for a
1468
+ reply notice to arrive, a run of spam to be spotted and a year of
1469
+ correspondence to be answered, and no longer. The first sweep after a site
1470
+ sets a period removes whatever is already past it.
1471
+
1472
+ A sweep runs when the site starts serving and every six hours after that, and
1473
+ `cms.retention.sweep()` runs one now. It reads the files rather than the index,
1474
+ rewrites each comment file and message inside the same lock every other writer
1475
+ takes, and puts each changed comment back into the index in the same step. A
1476
+ second sweep finds nothing left to remove and writes nothing.
1477
+
1478
+ A comment that loses its email keeps its id, its place in the thread, its
1479
+ status, its words and its name. It gains `"redacted": ["email"]`, which is how
1480
+ the index tells an email removed from one never given: a name whose email was
1481
+ removed does not count toward auto-approval, so nobody can type that name and
1482
+ skip the queue. Its author is no longer told about replies, and their next
1483
+ comment waits for a moderator. A moderation link in an inbox names the comment
1484
+ by id, so it goes on working.
1485
+
1486
+ The sweep changes the files as they are now. The emails are in `data/`, which
1487
+ is not in git, so removing one removes it. Emails from before they moved out of
1488
+ the comment files (TASK-182) were committed with those files, and they stay in
1489
+ the repository's history until that history is rewritten, whatever the sweep or
1490
+ an erasure does to the files today.
1491
+
1492
+ ### Erasing one person's data
1493
+
1494
+ **Tools > Personal data** erases what the site holds for one email address.
1495
+ It shows what it found first: how many comments carry the address, how many
1496
+ contact messages came from it, and whether it is on the opt-out list. Erasing
1497
+ then signs each of those comments `Anonymous`, removes its email, website,
1498
+ avatar and address hash, and marks it `"redacted": ["email", "addressHash",
1499
+ "author"]`; the comment keeps its place in the thread, its status and its words.
1500
+ The contact messages are deleted and the address comes off the opt-out list.
1501
+
1502
+ A comment is found by its email, so one whose email the sweep has already
1503
+ removed, a webmention and a fediverse reply are not found there. The Comments
1504
+ screen deletes those one at a time.
1505
+
1384
1506
  ## Keeping the index in step
1385
1507
 
1386
1508
  Booting scans `contentDir`, indexes every Markdown file under `posts/` and
@@ -1585,7 +1707,7 @@ shadow the login form.
1585
1707
  | `/admin/federation/resend` | `POST` only. Sends one post to the followers again, as its file now reads. |
1586
1708
  | `/admin/setup` | First run: creates the first admin. Closed once a user exists. |
1587
1709
  | `/admin/login` | Username and password. |
1588
- | `/admin/logout` | `POST` only. Deletes the session row. |
1710
+ | `/admin/logout` | `POST` only. Deletes the session row and sends `Clear-Site-Data`. |
1589
1711
  | `/admin/_static/*` | The admin's own stylesheet and scripts, cached for an hour. |
1590
1712
 
1591
1713
  The screens behind the login share one layout: a bar across the top with the
@@ -1642,11 +1764,13 @@ Passwords are hashed with argon2id through `node:crypto`, so there is no native
1642
1764
  module to build. The cost parameters travel with each hash, which means raising
1643
1765
  them later leaves every password already stored verifiable.
1644
1766
 
1645
- A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/admin`
1646
- cookie, with `Secure` added when `baseUrl` is an `https` URL. It lasts
1647
- `sessionLifetime`; an expired session is deleted rather than merely ignored.
1648
- Every mutating admin form carries a per-session CSRF token, and a `POST`
1649
- without a valid one is refused with 403 — including the login and setup forms,
1767
+ A session id is 256 random bits in a `HttpOnly; SameSite=Lax; Path=/` cookie.
1768
+ When `baseUrl` is an `https` URL the cookie is `Secure` and named
1769
+ `__Host-geekity_session`; over plain http it is `geekity_session`, as
1770
+ [Session hardening](#session-hardening) explains. It lasts `sessionLifetime`;
1771
+ an expired session is deleted rather than merely ignored. Every mutating admin
1772
+ form carries a per-session CSRF token, and a `POST` without a valid one is
1773
+ refused with 403 — including the login and setup forms,
1650
1774
  which get the token from a short anonymous session created when the form is
1651
1775
  first rendered. Logging in throws that session away and starts a new one, so a
1652
1776
  planted session id cannot become a logged-in one.
@@ -1716,7 +1840,8 @@ The file carries `title`, `tagline`, `url`, `author`, `postsPerPage`,
1716
1840
  `timezone`, `language`, `tagBase`,
1717
1841
  `categoryBase`, `notifyServer`, `webmentionsSend`, `webmentionsReceive`,
1718
1842
  `mailProvider`, `mailFromName`, `mailFromAddress`, `mailReplyTo`,
1719
- `contactEmail`, `relays`, `menus` and `taxonomyRedirects`,
1843
+ `contactEmail`, `securityContacts`, `securityPolicy`, `securityLanguages`,
1844
+ `relays`, `menus` and `taxonomyRedirects`,
1720
1845
  and every other key it already had is kept, `feedSize` and anything a site put
1721
1846
  there included. A key it does not carry is the default, and a key of the wrong
1722
1847
  type is the default too: a hand-edited `site.json` cannot take the site down.
@@ -1950,6 +2075,49 @@ header; the leftmost entry is then used. With neither available, which is what
1950
2075
  happens when the app is driven in process rather than served, the username is
1951
2076
  the only key.
1952
2077
 
2078
+ ### Session hardening
2079
+
2080
+ Three things protect a signed-in session beyond the cookie flags and the CSRF
2081
+ token.
2082
+
2083
+ **The cookie is `__Host-geekity_session` under https.** A browser accepts a
2084
+ cookie with the `__Host-` prefix only when it is `Secure`, has `Path=/` and
2085
+ names no `Domain`. That binds it to the exact host, so a page on a sibling
2086
+ subdomain cannot set or overwrite it to plant a session. The prefix needs
2087
+ `Secure`, and a browser drops a `Secure` cookie on a plain-http origin, so a
2088
+ site whose `baseUrl` is `http://localhost:3000` keeps the name
2089
+ `geekity_session` and local development works as before.
2090
+
2091
+ **The upgrade signs people out once.** A session made before this change is in
2092
+ a cookie named `geekity_session`. Under https that name is never accepted as a
2093
+ login again, because accepting it would give a sibling subdomain the way in
2094
+ that the prefix closes. The first admin request that carries only the old
2095
+ cookie deletes its session, expires the old cookie, and sends the browser to
2096
+ the login form with a message that the sign-in cookie was renamed and asks the
2097
+ person to sign in again. On the public site the old cookie is ignored, so a
2098
+ signed-in commenter sees the stranger's form until they sign in again.
2099
+
2100
+ **Fetch Metadata refuses cross-site writes.** A browser sends
2101
+ `Sec-Fetch-Site` with every request, and a page cannot change it. Every
2102
+ request under `/admin` other than `GET`, `HEAD` and `OPTIONS`, including the
2103
+ login and setup forms, is refused with 403 when that header says `cross-site`
2104
+ or `same-site`, before the session is read or a handler runs. A comment posted
2105
+ by somebody signed in gets the same check. `same-origin` and `none` pass. A
2106
+ request with no `Sec-Fetch-Site`, from an older browser or a script, falls back
2107
+ to the CSRF token, which every such request still has to carry. Nothing else
2108
+ is checked: the ActivityPub inboxes, the WordPress-compatible inbox,
2109
+ webmentions, the contact form, a stranger's comment, and the one-click
2110
+ moderate and unsubscribe links in an email take cross-site `POST`s by design,
2111
+ and they act on no session.
2112
+
2113
+ **Logout clears the site's data in the browser.** The logout response sends
2114
+ `Clear-Site-Data: "cache", "cookies", "storage"`, so a shared computer does not
2115
+ keep admin pages in its cache or anything the site stored. Browsers honour it
2116
+ only on a secure origin, which includes `http://localhost`. The `cookies`
2117
+ directive clears cookies for the whole registrable domain, so signing out of
2118
+ `blog.example.com` also signs the browser out of other sites under
2119
+ `example.com`.
2120
+
1953
2121
  ### Forgotten passwords
1954
2122
 
1955
2123
  `/admin/login` carries a **Forgotten your password?** link to `/admin/forgot`,
@@ -1993,12 +2161,67 @@ no restart.
1993
2161
  Every response the CMS sends carries `X-Content-Type-Options: nosniff`, and
1994
2162
  `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `baseUrl`
1995
2163
  is an `https` URL — the same test that decides whether the session cookie is
1996
- `Secure`, so the two cannot disagree. That is all the public site gets: a theme
1997
- is somebody else's HTML and the CMS has no business deciding what it may
1998
- reference.
2164
+ `Secure`, so the two cannot disagree. Neither can be turned off.
2165
+
2166
+ Every response also carries these, public pages, feeds, uploads, ActivityPub
2167
+ JSON, redirects, 404s, the maintenance 503 and the 500 page alike:
2168
+
2169
+ | Header | Default |
2170
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
2171
+ | `Referrer-Policy` | `strict-origin-when-cross-origin` |
2172
+ | `Content-Security-Policy` | `frame-ancestors 'self'` |
2173
+ | `X-Frame-Options` | `SAMEORIGIN` |
2174
+ | `Permissions-Policy` | `browsing-topics=(), camera=(), display-capture=(), geolocation=(), hid=(), microphone=(), midi=(), payment=(), serial=(), usb=(), xr-spatial-tracking=()` |
2175
+ | `Cross-Origin-Opener-Policy` | `same-origin` |
2176
+
2177
+ None of them limits what a page may load, and that is the line the CMS holds
2178
+ on the public site. There is no content policy, no `default-src`, `script-src`
2179
+ or `img-src`, because a theme is somebody else's HTML and the CMS has no
2180
+ business deciding what it may reference. `frame-ancestors` is the only
2181
+ directive in the policy, and it is about who may put the page in a frame, not
2182
+ what is in it. Each header does something else:
2183
+
2184
+ - `Referrer-Policy` sends only the origin, not the full URL, when a reader
2185
+ follows a link to another site, so the path and query of the page they were
2186
+ on stay on the site.
2187
+ - `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` stop another site
2188
+ from framing a page. That matters most for a signed-in user, whose pages carry
2189
+ a comment form that posts as them, but it covers every page because the CMS
2190
+ cannot tell a framed reader from a framed user.
2191
+ - `Permissions-Policy` turns off browser features a blog has no use for. It
2192
+ leaves alone what an embedded video player asks for: autoplay, fullscreen,
2193
+ `encrypted-media`, picture-in-picture, and the motion sensors behind a
2194
+ 360-degree video.
2195
+ - `Cross-Origin-Opener-Policy: same-origin` keeps a window the site opens, or a
2196
+ window that opens the site, from reaching into it through `window.opener`.
2197
+
2198
+ Two of them can get in the way of something a site does on purpose. A site that
2199
+ is meant to be framed elsewhere, such as in a portfolio or a slide deck, has to
2200
+ name that host in `frame-ancestors` and remove `X-Frame-Options`, which cannot
2201
+ name another host. A theme that signs readers in or takes payment through a
2202
+ popup on another origin needs `same-origin-allow-popups` for COOP. The CMS
2203
+ itself does neither.
2204
+
2205
+ A site changes them with `securityHeaders` in its config. Names are matched
2206
+ without regard to case. A string replaces a default or adds a header of the
2207
+ site's own, and `false` removes a default:
2208
+
2209
+ ```ts
2210
+ export default defineConfig({
2211
+ securityHeaders: {
2212
+ 'Content-Security-Policy':
2213
+ "frame-ancestors 'self' https://portfolio.example",
2214
+ 'X-Frame-Options': false,
2215
+ 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
2216
+ },
2217
+ });
2218
+ ```
2219
+
2220
+ A name that is not a header name, or a value that is empty or has a line break
2221
+ in it, stops the site at boot. There is no environment variable for these.
1999
2222
 
2000
- Every response under `/admin`, static files and redirects included, carries
2001
- three more:
2223
+ Every response under `/admin`, static files and redirects included, gets three
2224
+ stricter values in their place, whatever `securityHeaders` says:
2002
2225
 
2003
2226
  | Header | Value |
2004
2227
  | ------------------------- | --------------------------------------------- |
@@ -2027,35 +2250,109 @@ uploads. Four directives say more than that:
2027
2250
  sandboxed `srcdoc` iframe, which inherits this policy, and its one ancestor is
2028
2251
  the admin page itself.
2029
2252
 
2253
+ ### Compression
2254
+
2255
+ Text responses are compressed on the way out: HTML, CSS, JavaScript, feeds,
2256
+ JSON, Markdown, SVG, sitemaps and anything else whose media type is `text/*`,
2257
+ `+json` or `+xml`. A client that accepts brotli (`br`) gets brotli, at quality
2258
+ 5 because every page is built per request; one that accepts only gzip gets
2259
+ gzip; one that accepts neither gets the plain bytes.
2260
+
2261
+ Some responses go out as they are:
2262
+
2263
+ - Images, video, WOFF and WOFF2 fonts and other formats that carry their own
2264
+ compression. Encoding them again costs CPU and can make them bigger.
2265
+ - Bodies under 1024 bytes, where the saving is smaller than the cost.
2266
+ - A response that already has a `Content-Encoding`, or whose `Cache-Control`
2267
+ says `no-transform`.
2268
+ - A response that may hold a secret: anything whose `Cache-Control` says
2269
+ `private` or `no-store`, which every page drawn for somebody signed in does,
2270
+ and every admin screen under `/admin`, the setup and login forms included.
2271
+ These carry CSRF tokens next to text a visitor can influence, and compressing
2272
+ them would let an attacker who watches response sizes recover the token a
2273
+ byte at a time (the BREACH attack). Their `Vary` is left as it was. The
2274
+ admin's static files under `/admin/_static/` hold no secret and are
2275
+ compressed. Error pages say `no-store`, so they go out plain too.
2276
+
2277
+ Every other text response carries `Vary: Accept-Encoding`, whether it was
2278
+ compressed or not, and so does every `304`. A cache in front of the site
2279
+ therefore keeps a copy per encoding and never hands brotli to a client that
2280
+ cannot decode it.
2281
+
2282
+ A compressed response's `ETag` is the plain response's with `W/` in front: the
2283
+ bytes differ, but the representation is the same. `If-None-Match` compares
2284
+ weakly, so a browser holding the brotli copy and a feed reader holding the gzip
2285
+ copy both revalidate to `304`, and each gets back the validator it sent.
2286
+
2287
+ `compression: false`, or `GEEKITY_COMPRESSION=false`, turns all of this off.
2288
+ Turn it off when a reverse proxy or CDN in front of the site compresses
2289
+ responses itself (Caddy's `encode`, nginx's `gzip on`, Cloudflare), so the work
2290
+ is done once and the proxy's settings apply. Leave it on when nothing in front
2291
+ compresses, as in the Docker deployment the repository documents.
2292
+
2293
+ ### security.txt and change-password
2294
+
2295
+ `/.well-known/security.txt` ([RFC 9116](https://www.rfc-editor.org/rfc/rfc9116))
2296
+ tells somebody who finds a vulnerability in the site where to report it. It is
2297
+ built from three settings on the Email settings page, under Security contact,
2298
+ which `content/_data/site.json` holds:
2299
+
2300
+ | Setting | `site.json` key | Default | What it becomes |
2301
+ | ------------------- | ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
2302
+ | Contacts | `securityContacts` | `[]` | One `Contact:` line each, in order. An email address, stored as `mailto:…`, an `https://` URL or a `tel:` number. |
2303
+ | Disclosure policy | `securityPolicy` | `""` | A `Policy:` line. An `https://` URL, or empty for no line. |
2304
+ | Preferred languages | `securityLanguages` | `""` | A `Preferred-Languages:` line. Language tags separated by commas, or empty for no line. |
2305
+
2306
+ With no contact the path is a 404: the RFC requires at least one. The
2307
+ `contactEmail` setting is never used in its place, because that address is
2308
+ promised never to appear on the public site.
2309
+
2310
+ The file is built on every request and served as `text/plain; charset=utf-8`.
2311
+ `Expires` is 30 days after the request, so it never goes stale however long the
2312
+ site runs, and `Canonical` is the path under the base URL in effect. There is no
2313
+ config-file key or environment variable for any of these: like `contactEmail`,
2314
+ they are site settings a person edits on the settings screen.
2315
+
2316
+ `/.well-known/change-password` is where a password manager sends somebody who
2317
+ wants to change a saved password. It redirects with a `302` and
2318
+ `Cache-Control: no-store`: to `/admin/users/{id}#change-password`, the form on
2319
+ the signed-in user's own page, or to the login form when nobody is signed in.
2320
+
2321
+ Both paths answer as usual in maintenance mode. A researcher may need the
2322
+ security contact most while the site is down, and change-password only points
2323
+ into the admin, which maintenance mode leaves open.
2324
+
2030
2325
  ## The public site
2031
2326
 
2032
2327
  Booting mounts the public site on the app. The routes are:
2033
2328
 
2034
- | Route | What it serves |
2035
- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
2036
- | `/` | Published posts, newest first. |
2037
- | `/page/2/` and up | Later pages of the same archive. |
2038
- | a document's permalink | The post or the page, through the theme. |
2039
- | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
2040
- | `/category/{name}/` | The second taxonomy, paginated the same way. |
2041
- | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
2042
- | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
2043
- | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
2044
- | `/author/{username}/feed/` and siblings | The same, for one person. |
2045
- | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
2046
- | `{permalink}feed/` | One post's replies, the same way. |
2047
- | `/sitemap.xml` | Every public URL, for a search engine. |
2048
- | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
2049
- | `/robots.txt` | What a crawler may have, and where the sitemap is. |
2050
- | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
2051
- | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
2052
- | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
2053
- | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
2054
- | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
2055
- | `/theme/…` | The theme's own files, from its `static/` directory. |
2056
- | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
2057
- | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
2058
- | anything else | The theme's 404. |
2329
+ | Route | What it serves |
2330
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
2331
+ | `/` | Published posts, newest first. |
2332
+ | `/page/2/` and up | Later pages of the same archive. |
2333
+ | a document's permalink | The post or the page, through the theme. |
2334
+ | `/tag/{tag}/` | Everything published carrying that tag, paginated at `/tag/{tag}/page/2/`. |
2335
+ | `/category/{name}/` | The second taxonomy, paginated the same way. |
2336
+ | `/author/{username}/` | One user's published posts, headed by their profile, paginated the same way. |
2337
+ | `/feed/`, `/feed/atom/`, `/feed/json/` | The recent posts as RSS 2.0, Atom and JSON Feed. |
2338
+ | `/tag/{tag}/feed/` and its two siblings | The same, for one tag; `/category/{name}/feed/` likewise. |
2339
+ | `/author/{username}/feed/` and siblings | The same, for one person. |
2340
+ | `/comments/feed/` | Every reply the inbox has been sent, as RSS 2.0. |
2341
+ | `{permalink}feed/` | One post's replies, the same way. |
2342
+ | `/sitemap.xml` | Every public URL, for a search engine. |
2343
+ | `/sitemap-{n}.xml` | One file of a sitemap too big to be a single one. |
2344
+ | `/robots.txt` | What a crawler may have, and where the sitemap is. |
2345
+ | `/.well-known/security.txt` | Where to report a vulnerability; a 404 until a security contact is set. See [above](#securitytxt-and-change-password). |
2346
+ | `/.well-known/change-password` | A redirect to the signed-in user's change-password form, or to the login form. |
2347
+ | `/_geekity/comments` | `POST` only. Where the comment form under a post submits. |
2348
+ | `/_geekity/contact` | `POST` only. Where the contact form on a page submits. |
2349
+ | `/_geekity/webmention` | `POST` only. Where a webmention is sent; advertised on every document. |
2350
+ | `/_geekity/moderate` | Where an approve, spam or delete link from a notification lands. `GET` shows a button; `POST` acts. |
2351
+ | `/_geekity/unsubscribe` | Where the unsubscribe link in a reply notice lands. Same two steps. |
2352
+ | `/theme/…` | The theme's own files, from its `static/` directory. |
2353
+ | `/uploads/…` | A file from `content/uploads/`, byte for byte as it was stored. |
2354
+ | `/uploads/_/…` | One derived copy of an uploaded image, generated on the spot if missing. |
2355
+ | anything else | The theme's 404. |
2059
2356
 
2060
2357
  The federation routes go on before it, and answer only their own paths:
2061
2358
 
@@ -0,0 +1,26 @@
1
+ {# The admin bar, in the admin and across the top of the public site alike
2
+ (TASK-126, TASK-183). The site title leads to the dashboard; `barLinks` are
3
+ the shortcuts beside it, which each place chooses for itself; the account
4
+ menu ends it.
5
+
6
+ WordPress's "Howdy," menu, greeting the way this site's owner does. A
7
+ popover rather than a script: the browser opens and closes it, shuts it on
8
+ Escape or a click elsewhere, and tells a screen reader whether it is
9
+ expanded, all under the admin CSP's script-src 'self'. #}
10
+ <nav class="admin-bar" aria-label="Admin bar">
11
+ <a class="admin-bar-site" href="{{ adminUrl }}">{{ site.title }}</a>
12
+ {% for link in barLinks %}
13
+ <a href="{{ link.url }}">{{ link.label }}</a>
14
+ {% endfor %}
15
+ <span class="admin-bar-spacer"></span>
16
+ {% if me %}
17
+ <button type="button" class="admin-bar-account" popovertarget="admin-account-menu">Hoopla! {{ me.name }}</button>
18
+ <div id="admin-account-menu" class="admin-account-menu" popover>
19
+ <a href="{{ me.url }}">Edit profile</a>
20
+ <form method="post" action="{{ logoutUrl }}">
21
+ <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
22
+ <button type="submit">Log out</button>
23
+ </form>
24
+ </div>
25
+ {% endif %}
26
+ </nav>
@@ -0,0 +1,41 @@
1
+ {# The admin bar as the public site draws it for a signed-in user (TASK-183),
2
+ put straight after the theme's <body> tag.
3
+
4
+ Whatever theme the site wears has never heard of it, so it keeps to a shadow
5
+ root: no selector of the theme's reaches the markup inside, and the
6
+ stylesheet inside reaches nothing outside. The stylesheet is inlined rather
7
+ than linked so the bar is never drawn unstyled; the public site has no
8
+ content CSP to refuse it.
9
+
10
+ The host is fixed to the top of the window, as WordPress's is. In normal
11
+ flow it sat inside whatever padding or margin the theme gave <body>, so it
12
+ stopped short of the window's edges. Out of flow, nothing the theme does to
13
+ <body> moves it, and the inline reset keeps the theme's rules for the host
14
+ element itself, a `body > *` or a grid on <body>, from restyling it; only an
15
+ `!important` rule can, which is also how a theme that insists hides it. The
16
+ page is pushed down from outside, by a stylesheet the CMS puts in <head>
17
+ (OFFSET_STYLE in src/web/admin-bar.ts) reading --geekity-admin-bar-height.
18
+
19
+ The script does two things. It measures the bar and sets that height on
20
+ <html>, so a bar that wraps to two lines on a phone, or one a theme has
21
+ hidden, pushes the page down by what it really is. And it gives focus back
22
+ to the greeting when the menu closes with focus inside it: Chrome does that
23
+ for a popover in the light DOM, not from inside a shadow root, where the
24
+ focus is simply lost. A click elsewhere has already moved focus by then,
25
+ and keeps it. #}
26
+ <geekity-admin-bar style="all: initial; display: block; position: fixed; top: 0; left: 0; right: 0; z-index: 99999"><template shadowrootmode="open"><style>{% include "static/admin-bar.css" %}</style>{% include "components/admin-bar.njk" %}<script>
27
+ (() => {
28
+ const host = document.querySelector('geekity-admin-bar');
29
+ const root = host.shadowRoot;
30
+ new ResizeObserver(() => {
31
+ const height = host.getBoundingClientRect().height;
32
+ document.documentElement.style.setProperty('--geekity-admin-bar-height', `${height}px`);
33
+ }).observe(host);
34
+ const menu = root.getElementById('admin-account-menu');
35
+ menu?.addEventListener('beforetoggle', (event) => {
36
+ if (event.newState === 'closed' && menu.contains(root.activeElement)) {
37
+ setTimeout(() => root.querySelector('.admin-bar-account').focus());
38
+ }
39
+ });
40
+ })();
41
+ </script></template></geekity-admin-bar>
@@ -13,6 +13,7 @@
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1" />
14
14
  <title>{% block title %}Admin{% endblock %}{% if site.title %} · {{ site.title }}{% endif %}</title>
15
15
  <meta name="robots" content="noindex, nofollow" />
16
+ <link rel="stylesheet" href="{{ assetPrefix }}admin-bar.css" />
16
17
  <link rel="stylesheet" href="{{ assetPrefix }}admin.css" />
17
18
  </head>
18
19
  <body>
@@ -3,25 +3,14 @@
3
3
  {% extends "layouts/base.njk" %}
4
4
 
5
5
  {% block chrome %}
6
- <header class="admin-bar">
7
- <a class="admin-bar-site" href="{{ adminUrl }}">{{ site.title }}</a>
8
- <a href="{{ siteUrl }}">View site</a>
9
- <span class="admin-bar-spacer"></span>
10
- {% if me %}
11
- {# WordPress's "Howdy," menu, greeting the way this site's owner does.
12
- A popover rather than a script: the browser opens and closes it,
13
- shuts it on Escape or a click elsewhere, and tells a screen reader
14
- whether it is expanded, all under the CSP's script-src 'self'. #}
15
- <button type="button" class="admin-bar-account" popovertarget="admin-account-menu">Hoopla! {{ me.name }}</button>
16
- <div id="admin-account-menu" class="admin-account-menu" popover>
17
- <a href="{{ me.url }}">Edit profile</a>
18
- <form method="post" action="{{ logoutUrl }}">
19
- <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
20
- <button type="submit">Log out</button>
21
- </form>
22
- </div>
23
- {% endif %}
24
- </header>
6
+ {# View site, because in here you are not looking at it; + New, as on the
7
+ public site; and, on the editor of something readers can see, the way to
8
+ go and look at it. #}
9
+ {% set barLinks = [{ label: "View site", url: siteUrl }, { label: "+ New", url: newPostUrl }] %}
10
+ {% if viewUrl and kind %}
11
+ {% set barLinks = barLinks.concat([{ label: "View " + (kind.singular | capitalize), url: viewUrl }]) %}
12
+ {% endif %}
13
+ {% include "components/admin-bar.njk" %}
25
14
  {% endblock %}
26
15
 
27
16
  {% block main %}