@geekity/cms 0.3.0 → 0.5.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 (660) hide show
  1. package/README.md +2217 -214
  2. package/admin/components/fields.njk +165 -0
  3. package/admin/layouts/base.njk +1 -1
  4. package/admin/layouts/settings-page.njk +26 -0
  5. package/admin/layouts/shell.njk +17 -3
  6. package/admin/pages/account/forgot.njk +47 -0
  7. package/admin/pages/account/login.njk +26 -0
  8. package/admin/pages/account/reset.njk +38 -0
  9. package/admin/pages/account/setup.njk +28 -0
  10. package/admin/pages/appearance/themes.njk +69 -0
  11. package/admin/pages/comments/all.njk +133 -0
  12. package/admin/{layouts/dashboard.njk → pages/dashboard/home.njk} +8 -15
  13. package/admin/{layouts/document-conflict.njk → pages/documents/conflict.njk} +1 -0
  14. package/admin/{layouts/document-editor.njk → pages/documents/editor.njk} +75 -59
  15. package/admin/{layouts/document-list.njk → pages/documents/list.njk} +19 -2
  16. package/admin/pages/documents/taxonomy.njk +95 -0
  17. package/admin/pages/federation/followers.njk +238 -0
  18. package/admin/pages/federation/settings.njk +77 -0
  19. package/admin/pages/media/library.njk +160 -0
  20. package/admin/pages/messages/all.njk +110 -0
  21. package/admin/pages/navigation/menus.njk +129 -0
  22. package/admin/{layouts → pages}/placeholder.njk +5 -1
  23. package/admin/pages/settings/discussion.njk +99 -0
  24. package/admin/pages/settings/email.njk +203 -0
  25. package/admin/pages/settings/general.njk +29 -0
  26. package/admin/pages/settings/permalinks.njk +50 -0
  27. package/admin/pages/settings/reading.njk +64 -0
  28. package/admin/pages/tools/content-index.njk +89 -0
  29. package/admin/pages/users/edit.njk +239 -0
  30. package/admin/pages/users/list.njk +51 -0
  31. package/admin/pages/users/new.njk +41 -0
  32. package/admin/static/admin.css +558 -3
  33. package/admin/static/copy.js +52 -0
  34. package/admin/static/editor.js +16 -16
  35. package/admin/static/slug.js +61 -0
  36. package/dist/access-log.d.ts +44 -0
  37. package/dist/access-log.d.ts.map +1 -0
  38. package/dist/access-log.js +71 -0
  39. package/dist/access-log.js.map +1 -0
  40. package/dist/admin/accounts.d.ts +413 -0
  41. package/dist/admin/accounts.d.ts.map +1 -0
  42. package/dist/admin/accounts.js +680 -0
  43. package/dist/admin/accounts.js.map +1 -0
  44. package/dist/admin/appearance.d.ts +80 -0
  45. package/dist/admin/appearance.d.ts.map +1 -0
  46. package/dist/admin/appearance.js +136 -0
  47. package/dist/admin/appearance.js.map +1 -0
  48. package/dist/admin/comments.d.ts +90 -0
  49. package/dist/admin/comments.d.ts.map +1 -0
  50. package/dist/admin/comments.js +244 -0
  51. package/dist/admin/comments.js.map +1 -0
  52. package/dist/admin/credentials.d.ts +26 -2
  53. package/dist/admin/credentials.d.ts.map +1 -1
  54. package/dist/admin/credentials.js +35 -2
  55. package/dist/admin/credentials.js.map +1 -1
  56. package/dist/admin/documents.d.ts +117 -10
  57. package/dist/admin/documents.d.ts.map +1 -1
  58. package/dist/admin/documents.js +328 -41
  59. package/dist/admin/documents.js.map +1 -1
  60. package/dist/admin/federation.d.ts +161 -50
  61. package/dist/admin/federation.d.ts.map +1 -1
  62. package/dist/admin/federation.js +235 -93
  63. package/dist/admin/federation.js.map +1 -1
  64. package/dist/admin/formatting.d.ts +20 -0
  65. package/dist/admin/formatting.d.ts.map +1 -0
  66. package/dist/admin/formatting.js +72 -0
  67. package/dist/admin/formatting.js.map +1 -0
  68. package/dist/admin/headers.d.ts +65 -0
  69. package/dist/admin/headers.d.ts.map +1 -0
  70. package/dist/admin/headers.js +104 -0
  71. package/dist/admin/headers.js.map +1 -0
  72. package/dist/admin/index.d.ts +38 -12
  73. package/dist/admin/index.d.ts.map +1 -1
  74. package/dist/admin/index.js +25 -7
  75. package/dist/admin/index.js.map +1 -1
  76. package/dist/admin/media.d.ts +168 -0
  77. package/dist/admin/media.d.ts.map +1 -0
  78. package/dist/admin/media.js +295 -0
  79. package/dist/admin/media.js.map +1 -0
  80. package/dist/admin/menu.d.ts +79 -0
  81. package/dist/admin/menu.d.ts.map +1 -0
  82. package/dist/admin/menu.js +143 -0
  83. package/dist/admin/menu.js.map +1 -0
  84. package/dist/admin/messages.d.ts +80 -0
  85. package/dist/admin/messages.d.ts.map +1 -0
  86. package/dist/admin/messages.js +152 -0
  87. package/dist/admin/messages.js.map +1 -0
  88. package/dist/admin/navigation.d.ts +155 -0
  89. package/dist/admin/navigation.d.ts.map +1 -0
  90. package/dist/admin/navigation.js +292 -0
  91. package/dist/admin/navigation.js.map +1 -0
  92. package/dist/admin/preview.d.ts.map +1 -1
  93. package/dist/admin/preview.js +31 -7
  94. package/dist/admin/preview.js.map +1 -1
  95. package/dist/admin/recovery.d.ts +92 -0
  96. package/dist/admin/recovery.d.ts.map +1 -0
  97. package/dist/admin/recovery.js +229 -0
  98. package/dist/admin/recovery.js.map +1 -0
  99. package/dist/admin/routes.d.ts +0 -20
  100. package/dist/admin/routes.d.ts.map +1 -1
  101. package/dist/admin/routes.js +164 -105
  102. package/dist/admin/routes.js.map +1 -1
  103. package/dist/admin/session.d.ts +17 -3
  104. package/dist/admin/session.d.ts.map +1 -1
  105. package/dist/admin/session.js +19 -5
  106. package/dist/admin/session.js.map +1 -1
  107. package/dist/admin/settings-discussion.d.ts +37 -0
  108. package/dist/admin/settings-discussion.d.ts.map +1 -0
  109. package/dist/admin/settings-discussion.js +117 -0
  110. package/dist/admin/settings-discussion.js.map +1 -0
  111. package/dist/admin/settings-email.d.ts +54 -0
  112. package/dist/admin/settings-email.d.ts.map +1 -0
  113. package/dist/admin/settings-email.js +189 -0
  114. package/dist/admin/settings-email.js.map +1 -0
  115. package/dist/admin/settings-federation.d.ts +48 -0
  116. package/dist/admin/settings-federation.d.ts.map +1 -0
  117. package/dist/admin/settings-federation.js +123 -0
  118. package/dist/admin/settings-federation.js.map +1 -0
  119. package/dist/admin/settings-general.d.ts +12 -0
  120. package/dist/admin/settings-general.d.ts.map +1 -0
  121. package/dist/admin/settings-general.js +48 -0
  122. package/dist/admin/settings-general.js.map +1 -0
  123. package/dist/admin/settings-page.d.ts +119 -0
  124. package/dist/admin/settings-page.d.ts.map +1 -0
  125. package/dist/admin/settings-page.js +132 -0
  126. package/dist/admin/settings-page.js.map +1 -0
  127. package/dist/admin/settings-pages.d.ts +30 -0
  128. package/dist/admin/settings-pages.d.ts.map +1 -0
  129. package/dist/admin/settings-pages.js +43 -0
  130. package/dist/admin/settings-pages.js.map +1 -0
  131. package/dist/admin/settings-permalinks.d.ts +16 -0
  132. package/dist/admin/settings-permalinks.d.ts.map +1 -0
  133. package/dist/admin/settings-permalinks.js +32 -0
  134. package/dist/admin/settings-permalinks.js.map +1 -0
  135. package/dist/admin/settings-reading.d.ts +34 -0
  136. package/dist/admin/settings-reading.d.ts.map +1 -0
  137. package/dist/admin/settings-reading.js +49 -0
  138. package/dist/admin/settings-reading.js.map +1 -0
  139. package/dist/admin/settings.d.ts +339 -96
  140. package/dist/admin/settings.d.ts.map +1 -1
  141. package/dist/admin/settings.js +600 -308
  142. package/dist/admin/settings.js.map +1 -1
  143. package/dist/admin/store.d.ts +606 -152
  144. package/dist/admin/store.d.ts.map +1 -1
  145. package/dist/admin/store.js +863 -268
  146. package/dist/admin/store.js.map +1 -1
  147. package/dist/admin/taxonomy.d.ts +114 -0
  148. package/dist/admin/taxonomy.d.ts.map +1 -0
  149. package/dist/admin/taxonomy.js +289 -0
  150. package/dist/admin/taxonomy.js.map +1 -0
  151. package/dist/admin/templates.d.ts +46 -14
  152. package/dist/admin/templates.d.ts.map +1 -1
  153. package/dist/admin/templates.js +46 -14
  154. package/dist/admin/templates.js.map +1 -1
  155. package/dist/admin/throttle.d.ts +84 -0
  156. package/dist/admin/throttle.d.ts.map +1 -0
  157. package/dist/admin/throttle.js +143 -0
  158. package/dist/admin/throttle.js.map +1 -0
  159. package/dist/admin/tools.d.ts +111 -0
  160. package/dist/admin/tools.d.ts.map +1 -0
  161. package/dist/admin/tools.js +182 -0
  162. package/dist/admin/tools.js.map +1 -0
  163. package/dist/admin/uploads.d.ts +28 -2
  164. package/dist/admin/uploads.d.ts.map +1 -1
  165. package/dist/admin/uploads.js +43 -4
  166. package/dist/admin/uploads.js.map +1 -1
  167. package/dist/admin/users.d.ts +100 -13
  168. package/dist/admin/users.d.ts.map +1 -1
  169. package/dist/admin/users.js +412 -39
  170. package/dist/admin/users.js.map +1 -1
  171. package/dist/cache.d.ts +141 -0
  172. package/dist/cache.d.ts.map +1 -0
  173. package/dist/cache.js +207 -0
  174. package/dist/cache.js.map +1 -0
  175. package/dist/cli.d.ts +14 -2
  176. package/dist/cli.d.ts.map +1 -1
  177. package/dist/cli.js +429 -21
  178. package/dist/cli.js.map +1 -1
  179. package/dist/comments/akismet.d.ts +130 -0
  180. package/dist/comments/akismet.d.ts.map +1 -0
  181. package/dist/comments/akismet.js +274 -0
  182. package/dist/comments/akismet.js.map +1 -0
  183. package/dist/comments/conversation.d.ts +29 -0
  184. package/dist/comments/conversation.d.ts.map +1 -0
  185. package/dist/comments/conversation.js +60 -0
  186. package/dist/comments/conversation.js.map +1 -0
  187. package/dist/comments/form.d.ts +123 -0
  188. package/dist/comments/form.d.ts.map +1 -0
  189. package/dist/comments/form.js +79 -0
  190. package/dist/comments/form.js.map +1 -0
  191. package/dist/comments/index.d.ts +16 -0
  192. package/dist/comments/index.d.ts.map +1 -0
  193. package/dist/comments/index.js +10 -0
  194. package/dist/comments/index.js.map +1 -0
  195. package/dist/comments/markdown.d.ts +8 -0
  196. package/dist/comments/markdown.d.ts.map +1 -0
  197. package/dist/comments/markdown.js +85 -0
  198. package/dist/comments/markdown.js.map +1 -0
  199. package/dist/comments/moderate.d.ts +71 -0
  200. package/dist/comments/moderate.d.ts.map +1 -0
  201. package/dist/comments/moderate.js +88 -0
  202. package/dist/comments/moderate.js.map +1 -0
  203. package/dist/comments/policy.d.ts +57 -0
  204. package/dist/comments/policy.d.ts.map +1 -0
  205. package/dist/comments/policy.js +80 -0
  206. package/dist/comments/policy.js.map +1 -0
  207. package/dist/comments/records.d.ts +266 -0
  208. package/dist/comments/records.d.ts.map +1 -0
  209. package/dist/comments/records.js +487 -0
  210. package/dist/comments/records.js.map +1 -0
  211. package/dist/comments/routes.d.ts +60 -0
  212. package/dist/comments/routes.d.ts.map +1 -0
  213. package/dist/comments/routes.js +212 -0
  214. package/dist/comments/routes.js.map +1 -0
  215. package/dist/comments/submission.d.ts +297 -0
  216. package/dist/comments/submission.d.ts.map +1 -0
  217. package/dist/comments/submission.js +280 -0
  218. package/dist/comments/submission.js.map +1 -0
  219. package/dist/comments/viewer.d.ts +21 -0
  220. package/dist/comments/viewer.d.ts.map +1 -0
  221. package/dist/comments/viewer.js +45 -0
  222. package/dist/comments/viewer.js.map +1 -0
  223. package/dist/config.d.ts +225 -5
  224. package/dist/config.d.ts.map +1 -1
  225. package/dist/config.js +121 -6
  226. package/dist/config.js.map +1 -1
  227. package/dist/contact/delivery.d.ts +60 -0
  228. package/dist/contact/delivery.d.ts.map +1 -0
  229. package/dist/contact/delivery.js +87 -0
  230. package/dist/contact/delivery.js.map +1 -0
  231. package/dist/contact/form.d.ts +130 -0
  232. package/dist/contact/form.d.ts.map +1 -0
  233. package/dist/contact/form.js +163 -0
  234. package/dist/contact/form.js.map +1 -0
  235. package/dist/contact/index.d.ts +17 -0
  236. package/dist/contact/index.d.ts.map +1 -0
  237. package/dist/contact/index.js +13 -0
  238. package/dist/contact/index.js.map +1 -0
  239. package/dist/contact/records.d.ts +124 -0
  240. package/dist/contact/records.d.ts.map +1 -0
  241. package/dist/contact/records.js +218 -0
  242. package/dist/contact/records.js.map +1 -0
  243. package/dist/contact/routes.d.ts +17 -0
  244. package/dist/contact/routes.d.ts.map +1 -0
  245. package/dist/contact/routes.js +127 -0
  246. package/dist/contact/routes.js.map +1 -0
  247. package/dist/contact/submission.d.ts +103 -0
  248. package/dist/contact/submission.d.ts.map +1 -0
  249. package/dist/contact/submission.js +166 -0
  250. package/dist/contact/submission.js.map +1 -0
  251. package/dist/content/document.d.ts +7 -2
  252. package/dist/content/document.d.ts.map +1 -1
  253. package/dist/content/document.js +1 -0
  254. package/dist/content/document.js.map +1 -1
  255. package/dist/content/index.d.ts +7 -2
  256. package/dist/content/index.d.ts.map +1 -1
  257. package/dist/content/index.js +4 -1
  258. package/dist/content/index.js.map +1 -1
  259. package/dist/content/markdown.d.ts.map +1 -1
  260. package/dist/content/markdown.js +24 -2
  261. package/dist/content/markdown.js.map +1 -1
  262. package/dist/content/parser.d.ts.map +1 -1
  263. package/dist/content/parser.js +11 -4
  264. package/dist/content/parser.js.map +1 -1
  265. package/dist/content/save.d.ts +5 -0
  266. package/dist/content/save.d.ts.map +1 -1
  267. package/dist/content/save.js +44 -1
  268. package/dist/content/save.js.map +1 -1
  269. package/dist/content/schedule.d.ts +125 -0
  270. package/dist/content/schedule.d.ts.map +1 -0
  271. package/dist/content/schedule.js +207 -0
  272. package/dist/content/schedule.js.map +1 -0
  273. package/dist/content/search.d.ts +64 -0
  274. package/dist/content/search.d.ts.map +1 -0
  275. package/dist/content/search.js +136 -0
  276. package/dist/content/search.js.map +1 -0
  277. package/dist/content/store.d.ts +220 -7
  278. package/dist/content/store.d.ts.map +1 -1
  279. package/dist/content/store.js +392 -67
  280. package/dist/content/store.js.map +1 -1
  281. package/dist/content/sync.d.ts +7 -1
  282. package/dist/content/sync.d.ts.map +1 -1
  283. package/dist/content/sync.js +18 -10
  284. package/dist/content/sync.js.map +1 -1
  285. package/dist/content/time.d.ts +56 -0
  286. package/dist/content/time.d.ts.map +1 -0
  287. package/dist/content/time.js +141 -0
  288. package/dist/content/time.js.map +1 -0
  289. package/dist/content/writer.d.ts.map +1 -1
  290. package/dist/content/writer.js +3 -0
  291. package/dist/content/writer.js.map +1 -1
  292. package/dist/env.d.ts +70 -5
  293. package/dist/env.d.ts.map +1 -1
  294. package/dist/federation/actor.d.ts +95 -41
  295. package/dist/federation/actor.d.ts.map +1 -1
  296. package/dist/federation/actor.js +180 -53
  297. package/dist/federation/actor.js.map +1 -1
  298. package/dist/federation/article.d.ts +44 -17
  299. package/dist/federation/article.d.ts.map +1 -1
  300. package/dist/federation/article.js +98 -46
  301. package/dist/federation/article.js.map +1 -1
  302. package/dist/federation/delivery.d.ts +63 -15
  303. package/dist/federation/delivery.d.ts.map +1 -1
  304. package/dist/federation/delivery.js +154 -80
  305. package/dist/federation/delivery.js.map +1 -1
  306. package/dist/federation/federation.d.ts +22 -17
  307. package/dist/federation/federation.d.ts.map +1 -1
  308. package/dist/federation/federation.js +98 -77
  309. package/dist/federation/federation.js.map +1 -1
  310. package/dist/federation/followers.d.ts +3 -2
  311. package/dist/federation/followers.d.ts.map +1 -1
  312. package/dist/federation/followers.js +6 -5
  313. package/dist/federation/followers.js.map +1 -1
  314. package/dist/federation/import-wordpress.d.ts +128 -0
  315. package/dist/federation/import-wordpress.d.ts.map +1 -0
  316. package/dist/federation/import-wordpress.js +426 -0
  317. package/dist/federation/import-wordpress.js.map +1 -0
  318. package/dist/federation/inbox.d.ts +36 -13
  319. package/dist/federation/inbox.d.ts.map +1 -1
  320. package/dist/federation/inbox.js +120 -49
  321. package/dist/federation/inbox.js.map +1 -1
  322. package/dist/federation/index.d.ts +22 -10
  323. package/dist/federation/index.d.ts.map +1 -1
  324. package/dist/federation/index.js +13 -8
  325. package/dist/federation/index.js.map +1 -1
  326. package/dist/federation/keys.d.ts +98 -20
  327. package/dist/federation/keys.d.ts.map +1 -1
  328. package/dist/federation/keys.js +269 -39
  329. package/dist/federation/keys.js.map +1 -1
  330. package/dist/federation/mount.d.ts +46 -10
  331. package/dist/federation/mount.d.ts.map +1 -1
  332. package/dist/federation/mount.js +300 -28
  333. package/dist/federation/mount.js.map +1 -1
  334. package/dist/federation/paths.d.ts +23 -35
  335. package/dist/federation/paths.d.ts.map +1 -1
  336. package/dist/federation/paths.js +26 -39
  337. package/dist/federation/paths.js.map +1 -1
  338. package/dist/federation/records.d.ts +223 -0
  339. package/dist/federation/records.d.ts.map +1 -0
  340. package/dist/federation/records.js +506 -0
  341. package/dist/federation/records.js.map +1 -0
  342. package/dist/federation/relays.d.ts +114 -0
  343. package/dist/federation/relays.d.ts.map +1 -0
  344. package/dist/federation/relays.js +290 -0
  345. package/dist/federation/relays.js.map +1 -0
  346. package/dist/federation/replies.d.ts +80 -0
  347. package/dist/federation/replies.d.ts.map +1 -0
  348. package/dist/federation/replies.js +166 -0
  349. package/dist/federation/replies.js.map +1 -0
  350. package/dist/federation/wordpress.d.ts +131 -0
  351. package/dist/federation/wordpress.d.ts.map +1 -0
  352. package/dist/federation/wordpress.js +341 -0
  353. package/dist/federation/wordpress.js.map +1 -0
  354. package/dist/files/atomic.d.ts +103 -0
  355. package/dist/files/atomic.d.ts.map +1 -0
  356. package/dist/files/atomic.js +150 -0
  357. package/dist/files/atomic.js.map +1 -0
  358. package/dist/files/index.d.ts +3 -0
  359. package/dist/files/index.d.ts.map +1 -0
  360. package/dist/files/index.js +2 -0
  361. package/dist/files/index.js.map +1 -0
  362. package/dist/forms/protection.d.ts +85 -0
  363. package/dist/forms/protection.d.ts.map +1 -0
  364. package/dist/forms/protection.js +116 -0
  365. package/dist/forms/protection.js.map +1 -0
  366. package/dist/images/icons.d.ts +59 -0
  367. package/dist/images/icons.d.ts.map +1 -0
  368. package/dist/images/icons.js +131 -0
  369. package/dist/images/icons.js.map +1 -0
  370. package/dist/images/index.d.ts +7 -0
  371. package/dist/images/index.d.ts.map +1 -0
  372. package/dist/images/index.js +4 -0
  373. package/dist/images/index.js.map +1 -0
  374. package/dist/images/markup.d.ts +47 -0
  375. package/dist/images/markup.d.ts.map +1 -0
  376. package/dist/images/markup.js +175 -0
  377. package/dist/images/markup.js.map +1 -0
  378. package/dist/images/paths.d.ts +34 -0
  379. package/dist/images/paths.d.ts.map +1 -0
  380. package/dist/images/paths.js +41 -0
  381. package/dist/images/paths.js.map +1 -0
  382. package/dist/images/variants.d.ts +104 -0
  383. package/dist/images/variants.d.ts.map +1 -0
  384. package/dist/images/variants.js +320 -0
  385. package/dist/images/variants.js.map +1 -0
  386. package/dist/index.d.ts +123 -19
  387. package/dist/index.d.ts.map +1 -1
  388. package/dist/index.js +344 -25
  389. package/dist/index.js.map +1 -1
  390. package/dist/init.d.ts +18 -0
  391. package/dist/init.d.ts.map +1 -1
  392. package/dist/init.js +34 -3
  393. package/dist/init.js.map +1 -1
  394. package/dist/mail/brevo.d.ts +35 -0
  395. package/dist/mail/brevo.d.ts.map +1 -0
  396. package/dist/mail/brevo.js +85 -0
  397. package/dist/mail/brevo.js.map +1 -0
  398. package/dist/mail/credentials.d.ts +62 -0
  399. package/dist/mail/credentials.d.ts.map +1 -0
  400. package/dist/mail/credentials.js +86 -0
  401. package/dist/mail/credentials.js.map +1 -0
  402. package/dist/mail/index.d.ts +15 -0
  403. package/dist/mail/index.d.ts.map +1 -0
  404. package/dist/mail/index.js +8 -0
  405. package/dist/mail/index.js.map +1 -0
  406. package/dist/mail/memory.d.ts +35 -0
  407. package/dist/mail/memory.d.ts.map +1 -0
  408. package/dist/mail/memory.js +29 -0
  409. package/dist/mail/memory.js.map +1 -0
  410. package/dist/mail/provider.d.ts +80 -0
  411. package/dist/mail/provider.d.ts.map +1 -0
  412. package/dist/mail/provider.js +23 -0
  413. package/dist/mail/provider.js.map +1 -0
  414. package/dist/mail/service.d.ts +156 -0
  415. package/dist/mail/service.d.ts.map +1 -0
  416. package/dist/mail/service.js +265 -0
  417. package/dist/mail/service.js.map +1 -0
  418. package/dist/mail/smtp.d.ts +39 -0
  419. package/dist/mail/smtp.d.ts.map +1 -0
  420. package/dist/mail/smtp.js +58 -0
  421. package/dist/mail/smtp.js.map +1 -0
  422. package/dist/mail/templates.d.ts +91 -0
  423. package/dist/mail/templates.d.ts.map +1 -0
  424. package/dist/mail/templates.js +69 -0
  425. package/dist/mail/templates.js.map +1 -0
  426. package/dist/notifications/comments.d.ts +66 -0
  427. package/dist/notifications/comments.d.ts.map +1 -0
  428. package/dist/notifications/comments.js +151 -0
  429. package/dist/notifications/comments.js.map +1 -0
  430. package/dist/notifications/digest.d.ts +135 -0
  431. package/dist/notifications/digest.d.ts.map +1 -0
  432. package/dist/notifications/digest.js +287 -0
  433. package/dist/notifications/digest.js.map +1 -0
  434. package/dist/notifications/index.d.ts +13 -0
  435. package/dist/notifications/index.d.ts.map +1 -0
  436. package/dist/notifications/index.js +8 -0
  437. package/dist/notifications/index.js.map +1 -0
  438. package/dist/notifications/links.d.ts +60 -0
  439. package/dist/notifications/links.d.ts.map +1 -0
  440. package/dist/notifications/links.js +63 -0
  441. package/dist/notifications/links.js.map +1 -0
  442. package/dist/notifications/optouts.d.ts +48 -0
  443. package/dist/notifications/optouts.d.ts.map +1 -0
  444. package/dist/notifications/optouts.js +93 -0
  445. package/dist/notifications/optouts.js.map +1 -0
  446. package/dist/notifications/preferences.d.ts +158 -0
  447. package/dist/notifications/preferences.d.ts.map +1 -0
  448. package/dist/notifications/preferences.js +195 -0
  449. package/dist/notifications/preferences.js.map +1 -0
  450. package/dist/notifications/routes.d.ts +28 -0
  451. package/dist/notifications/routes.d.ts.map +1 -0
  452. package/dist/notifications/routes.js +184 -0
  453. package/dist/notifications/routes.js.map +1 -0
  454. package/dist/notifications/tokens.d.ts +60 -0
  455. package/dist/notifications/tokens.d.ts.map +1 -0
  456. package/dist/notifications/tokens.js +125 -0
  457. package/dist/notifications/tokens.js.map +1 -0
  458. package/dist/notify.d.ts +84 -0
  459. package/dist/notify.d.ts.map +1 -0
  460. package/dist/notify.js +149 -0
  461. package/dist/notify.js.map +1 -0
  462. package/dist/web/archive.d.ts +49 -0
  463. package/dist/web/archive.d.ts.map +1 -0
  464. package/dist/web/archive.js +59 -0
  465. package/dist/web/archive.js.map +1 -0
  466. package/dist/web/assets.d.ts +6 -5
  467. package/dist/web/assets.d.ts.map +1 -1
  468. package/dist/web/assets.js +5 -7
  469. package/dist/web/assets.js.map +1 -1
  470. package/dist/web/authors.d.ts +143 -0
  471. package/dist/web/authors.d.ts.map +1 -0
  472. package/dist/web/authors.js +178 -0
  473. package/dist/web/authors.js.map +1 -0
  474. package/dist/web/comments.d.ts +60 -0
  475. package/dist/web/comments.d.ts.map +1 -0
  476. package/dist/web/comments.js +207 -0
  477. package/dist/web/comments.js.map +1 -0
  478. package/dist/web/context.d.ts +162 -28
  479. package/dist/web/context.d.ts.map +1 -1
  480. package/dist/web/context.js +123 -26
  481. package/dist/web/context.js.map +1 -1
  482. package/dist/web/conversation.d.ts +209 -0
  483. package/dist/web/conversation.d.ts.map +1 -0
  484. package/dist/web/conversation.js +557 -0
  485. package/dist/web/conversation.js.map +1 -0
  486. package/dist/web/documents.d.ts +36 -6
  487. package/dist/web/documents.d.ts.map +1 -1
  488. package/dist/web/documents.js +52 -10
  489. package/dist/web/documents.js.map +1 -1
  490. package/dist/web/feed-atom.d.ts +22 -0
  491. package/dist/web/feed-atom.d.ts.map +1 -0
  492. package/dist/web/feed-atom.js +83 -0
  493. package/dist/web/feed-atom.js.map +1 -0
  494. package/dist/web/feed-item.d.ts +145 -0
  495. package/dist/web/feed-item.d.ts.map +1 -0
  496. package/dist/web/feed-item.js +120 -0
  497. package/dist/web/feed-item.js.map +1 -0
  498. package/dist/web/feed-json.d.ts +79 -0
  499. package/dist/web/feed-json.d.ts.map +1 -0
  500. package/dist/web/feed-json.js +53 -0
  501. package/dist/web/feed-json.js.map +1 -0
  502. package/dist/web/feed-rss.d.ts +26 -0
  503. package/dist/web/feed-rss.d.ts.map +1 -0
  504. package/dist/web/feed-rss.js +203 -0
  505. package/dist/web/feed-rss.js.map +1 -0
  506. package/dist/web/feed-source.d.ts +244 -0
  507. package/dist/web/feed-source.d.ts.map +1 -0
  508. package/dist/web/feed-source.js +201 -0
  509. package/dist/web/feed-source.js.map +1 -0
  510. package/dist/web/feed-xml.d.ts +63 -0
  511. package/dist/web/feed-xml.d.ts.map +1 -0
  512. package/dist/web/feed-xml.js +116 -0
  513. package/dist/web/feed-xml.js.map +1 -0
  514. package/dist/web/feeds.d.ts +31 -96
  515. package/dist/web/feeds.d.ts.map +1 -1
  516. package/dist/web/feeds.js +94 -198
  517. package/dist/web/feeds.js.map +1 -1
  518. package/dist/web/health.d.ts +31 -0
  519. package/dist/web/health.d.ts.map +1 -0
  520. package/dist/web/health.js +44 -0
  521. package/dist/web/health.js.map +1 -0
  522. package/dist/web/index.d.ts +30 -10
  523. package/dist/web/index.d.ts.map +1 -1
  524. package/dist/web/index.js +18 -7
  525. package/dist/web/index.js.map +1 -1
  526. package/dist/web/navigation.d.ts +195 -0
  527. package/dist/web/navigation.d.ts.map +1 -0
  528. package/dist/web/navigation.js +300 -0
  529. package/dist/web/navigation.js.map +1 -0
  530. package/dist/web/negotiate.d.ts +31 -0
  531. package/dist/web/negotiate.d.ts.map +1 -1
  532. package/dist/web/negotiate.js +31 -11
  533. package/dist/web/negotiate.js.map +1 -1
  534. package/dist/web/recent.d.ts +43 -0
  535. package/dist/web/recent.d.ts.map +1 -0
  536. package/dist/web/recent.js +37 -0
  537. package/dist/web/recent.js.map +1 -0
  538. package/dist/web/render.d.ts +203 -9
  539. package/dist/web/render.d.ts.map +1 -1
  540. package/dist/web/render.js +341 -17
  541. package/dist/web/render.js.map +1 -1
  542. package/dist/web/routes.d.ts +25 -8
  543. package/dist/web/routes.d.ts.map +1 -1
  544. package/dist/web/routes.js +854 -93
  545. package/dist/web/routes.js.map +1 -1
  546. package/dist/web/sanitize.d.ts +19 -0
  547. package/dist/web/sanitize.d.ts.map +1 -0
  548. package/dist/web/sanitize.js +272 -0
  549. package/dist/web/sanitize.js.map +1 -0
  550. package/dist/web/search.d.ts +79 -0
  551. package/dist/web/search.d.ts.map +1 -0
  552. package/dist/web/search.js +94 -0
  553. package/dist/web/search.js.map +1 -0
  554. package/dist/web/sitemap.d.ts +120 -0
  555. package/dist/web/sitemap.d.ts.map +1 -0
  556. package/dist/web/sitemap.js +199 -0
  557. package/dist/web/sitemap.js.map +1 -0
  558. package/dist/web/taxonomy.d.ts +152 -0
  559. package/dist/web/taxonomy.d.ts.map +1 -0
  560. package/dist/web/taxonomy.js +237 -0
  561. package/dist/web/taxonomy.js.map +1 -0
  562. package/dist/web/templates.d.ts +54 -16
  563. package/dist/web/templates.d.ts.map +1 -1
  564. package/dist/web/templates.js +109 -28
  565. package/dist/web/templates.js.map +1 -1
  566. package/dist/web/themes.d.ts +226 -0
  567. package/dist/web/themes.d.ts.map +1 -0
  568. package/dist/web/themes.js +314 -0
  569. package/dist/web/themes.js.map +1 -0
  570. package/dist/webmention/discovery.d.ts +51 -0
  571. package/dist/webmention/discovery.d.ts.map +1 -0
  572. package/dist/webmention/discovery.js +176 -0
  573. package/dist/webmention/discovery.js.map +1 -0
  574. package/dist/webmention/html.d.ts +71 -0
  575. package/dist/webmention/html.d.ts.map +1 -0
  576. package/dist/webmention/html.js +285 -0
  577. package/dist/webmention/html.js.map +1 -0
  578. package/dist/webmention/index.d.ts +12 -0
  579. package/dist/webmention/index.d.ts.map +1 -0
  580. package/dist/webmention/index.js +8 -0
  581. package/dist/webmention/index.js.map +1 -0
  582. package/dist/webmention/links.d.ts +18 -0
  583. package/dist/webmention/links.d.ts.map +1 -0
  584. package/dist/webmention/links.js +64 -0
  585. package/dist/webmention/links.js.map +1 -0
  586. package/dist/webmention/microformats.d.ts +93 -0
  587. package/dist/webmention/microformats.d.ts.map +1 -0
  588. package/dist/webmention/microformats.js +376 -0
  589. package/dist/webmention/microformats.js.map +1 -0
  590. package/dist/webmention/receive.d.ts +141 -0
  591. package/dist/webmention/receive.d.ts.map +1 -0
  592. package/dist/webmention/receive.js +264 -0
  593. package/dist/webmention/receive.js.map +1 -0
  594. package/dist/webmention/routes.d.ts +37 -0
  595. package/dist/webmention/routes.d.ts.map +1 -0
  596. package/dist/webmention/routes.js +75 -0
  597. package/dist/webmention/routes.js.map +1 -0
  598. package/dist/webmention/service.d.ts +94 -0
  599. package/dist/webmention/service.d.ts.map +1 -0
  600. package/dist/webmention/service.js +207 -0
  601. package/dist/webmention/service.js.map +1 -0
  602. package/package.json +11 -6
  603. package/templates/site/content/_data/site.json +8 -0
  604. package/templates/site/content/pages/about.md +7 -0
  605. package/templates/site/content/posts/2026-01-01-hello-world.md +5 -3
  606. package/templates/site/geekity.config.ts +11 -1
  607. package/templates/site/tsconfig.json +1 -1
  608. package/themes/default/README.md +1216 -50
  609. package/themes/default/layouts/404.njk +2 -3
  610. package/themes/default/layouts/author.njk +36 -0
  611. package/themes/default/layouts/base.njk +161 -31
  612. package/themes/default/layouts/category.njk +27 -0
  613. package/themes/default/layouts/front-page.njk +56 -0
  614. package/themes/default/layouts/home.njk +17 -1
  615. package/themes/default/layouts/page.njk +57 -5
  616. package/themes/default/layouts/post.njk +78 -19
  617. package/themes/default/layouts/search.njk +57 -0
  618. package/themes/default/layouts/tag.njk +3 -6
  619. package/themes/default/mail/comment-digest.html.njk +37 -0
  620. package/themes/default/mail/comment-digest.subject.njk +3 -0
  621. package/themes/default/mail/comment-digest.txt.njk +40 -0
  622. package/themes/default/mail/comment-pending.html.njk +27 -0
  623. package/themes/default/mail/comment-pending.subject.njk +2 -0
  624. package/themes/default/mail/comment-pending.txt.njk +24 -0
  625. package/themes/default/mail/comment-reply.html.njk +21 -0
  626. package/themes/default/mail/comment-reply.subject.njk +2 -0
  627. package/themes/default/mail/comment-reply.txt.njk +22 -0
  628. package/themes/default/mail/contact-message.html.njk +24 -0
  629. package/themes/default/mail/contact-message.subject.njk +5 -0
  630. package/themes/default/mail/contact-message.txt.njk +20 -0
  631. package/themes/default/mail/password-changed.html.njk +18 -0
  632. package/themes/default/mail/password-changed.subject.njk +2 -0
  633. package/themes/default/mail/password-changed.txt.njk +17 -0
  634. package/themes/default/mail/password-reset.html.njk +22 -0
  635. package/themes/default/mail/password-reset.subject.njk +2 -0
  636. package/themes/default/mail/password-reset.txt.njk +18 -0
  637. package/themes/default/mail/test.html.njk +14 -0
  638. package/themes/default/mail/test.subject.njk +2 -0
  639. package/themes/default/mail/test.txt.njk +12 -0
  640. package/themes/default/partials/archive.njk +27 -0
  641. package/themes/default/partials/bio.njk +71 -0
  642. package/themes/default/partials/comment-form.njk +137 -0
  643. package/themes/default/partials/contact-form.njk +88 -0
  644. package/themes/default/partials/conversation.njk +139 -0
  645. package/themes/default/partials/feeds.njk +26 -0
  646. package/themes/default/partials/jsonld.njk +120 -0
  647. package/themes/default/partials/menu.njk +32 -0
  648. package/themes/default/partials/pagination.njk +12 -6
  649. package/themes/default/partials/post-list.njk +51 -18
  650. package/themes/default/partials/search-form.njk +22 -0
  651. package/themes/default/partials/tags.njk +26 -5
  652. package/themes/default/static/highlight.js +4 -0
  653. package/themes/default/static/style.css +1142 -194
  654. package/themes/default/theme.json +9 -0
  655. package/admin/layouts/federation.njk +0 -145
  656. package/admin/layouts/login.njk +0 -23
  657. package/admin/layouts/settings.njk +0 -116
  658. package/admin/layouts/setup.njk +0 -27
  659. package/admin/layouts/users.njk +0 -91
  660. /package/admin/{layouts/_flash.njk → components/flash.njk} +0 -0
@@ -1,32 +1,27 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { mkdirSync } from 'node:fs';
3
- import path from 'node:path';
1
+ import { createHash, randomBytes } from 'node:crypto';
4
2
  import { DatabaseSync } from 'node:sqlite';
5
- import { DATABASE_FILE } from '../content/store.js';
6
- import { hashPassword, verifyPasswordHash } from './passwords.js';
7
- /** How long a session id is, in bytes. 32 is the 256 bits doc-5 asks for. */
8
- export const SESSION_ID_BYTES = 32;
3
+ import { databaseFile, openDatabase } from '../cache.js';
4
+ import { REPLY_ACTIVITY_TYPE, replyTargetOf } from '../federation/replies.js';
9
5
  /**
10
- * The two signature algorithms doc-4 asks a site actor to hold.
11
- *
12
- * `RSASSA-PKCS1-v1_5` signs HTTP Signatures, which is what Mastodon and most
13
- * of the fediverse verify; `Ed25519` signs FEP-8b32 object integrity proofs.
14
- * They are Fedify's own spellings, so a row round-trips into
15
- * `generateCryptoKeyPair` without translation.
6
+ * Which rows of the inbox log are replies: a `Create` that named something it
7
+ * answers. Spelled once, because a count and a listing that disagreed about it
8
+ * would show a post a number no page of comments could produce.
16
9
  */
17
- export const ACTOR_KEY_ALGORITHMS = ['RSASSA-PKCS1-v1_5', 'Ed25519'];
10
+ const IS_REPLY = `activity_type = '${REPLY_ACTIVITY_TYPE}' AND in_reply_to IS NOT NULL`;
11
+ /** How long a session id is, in bytes. 32 is the 256 bits doc-5 asks for. */
12
+ export const SESSION_ID_BYTES = 32;
18
13
  /** How one delivery of one activity to one follower ended. */
19
14
  export const DELIVERY_STATUSES = ['sent', 'queued', 'failed'];
20
- /** Thrown when a username is already taken. Usernames are the login key. */
21
- export class DuplicateUsernameError extends Error {
22
- name = 'DuplicateUsernameError';
23
- /** The name that was already in use. */
24
- username;
25
- constructor(username) {
26
- super(`A user named "${username}" already exists.`);
27
- this.username = username;
28
- }
29
- }
15
+ /** Where a relay subscription stands (FEP-ae0c). */
16
+ export const RELAY_STATES = ['pending', 'accepted', 'rejected'];
17
+ /** How one webmention this site sent to one page ended. */
18
+ export const WEBMENTION_SEND_STATUSES = ['sent', 'none', 'failed'];
19
+ /** Where a comment came from. */
20
+ export const COMMENT_SOURCES = ['comment', 'webmention'];
21
+ /** What a comment is: something written, or a wordless pointer at the post. */
22
+ export const COMMENT_KINDS = ['reply', 'like', 'boost', 'mention', 'repost'];
23
+ /** Where a comment stands with the moderator. */
24
+ export const COMMENT_STATUSES = ['pending', 'approved', 'spam'];
30
25
  /**
31
26
  * Open (and if needed create) the admin tables in `dataDir`, applying every
32
27
  * migration the package ships. Applying them is idempotent.
@@ -35,20 +30,9 @@ export class DuplicateUsernameError extends Error {
35
30
  * index holding a second one on the same file.
36
31
  */
37
32
  export function openAdminStore(options) {
38
- const file = path.join(options.dataDir, DATABASE_FILE);
39
- mkdirSync(options.dataDir, { recursive: true });
40
- const db = new DatabaseSync(file);
41
- db.exec('PRAGMA journal_mode = WAL');
42
- db.exec('PRAGMA foreign_keys = ON');
43
- migrate(db);
33
+ const file = databaseFile(options.dataDir);
34
+ const db = openDatabase({ dataDir: options.dataDir, ledger: LEDGER, migrations: MIGRATIONS });
44
35
  const statements = {
45
- countUsers: db.prepare('SELECT COUNT(*) AS count FROM users'),
46
- listUsers: db.prepare('SELECT id, username, created_at FROM users ORDER BY username'),
47
- userByName: db.prepare('SELECT * FROM users WHERE username = ?'),
48
- userById: db.prepare('SELECT id, username, created_at FROM users WHERE id = ?'),
49
- insertUser: db.prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'),
50
- setPassword: db.prepare('UPDATE users SET password_hash = ? WHERE id = ?'),
51
- deleteUser: db.prepare('DELETE FROM users WHERE id = ?'),
52
36
  deleteSessionsForUser: db.prepare('DELETE FROM sessions WHERE user_id = ? AND id IS NOT ?'),
53
37
  insertSession: db.prepare(`
54
38
  INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at)
@@ -59,33 +43,38 @@ export function openAdminStore(options) {
59
43
  pruneSessions: db.prepare('DELETE FROM sessions WHERE expires_at <= ?'),
60
44
  readFlash: db.prepare('SELECT flash FROM sessions WHERE id = ?'),
61
45
  writeFlash: db.prepare('UPDATE sessions SET flash = ? WHERE id = ?'),
62
- countSettings: db.prepare('SELECT COUNT(*) AS count FROM settings'),
63
- allSettings: db.prepare('SELECT key, value FROM settings'),
64
- putSetting: db.prepare(`
65
- INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
66
- ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
46
+ insertPasswordReset: db.prepare(`
47
+ INSERT INTO password_resets (token_hash, user_id, created_at, expires_at)
48
+ VALUES (?, ?, ?, ?)
67
49
  `),
68
- actorKeys: db.prepare('SELECT * FROM actor_keys WHERE identifier = ?'),
69
- putActorKey: db.prepare(`
70
- INSERT INTO actor_keys (identifier, algorithm, private_jwk, public_jwk, created_at)
71
- VALUES (?, ?, ?, ?, ?)
72
- ON CONFLICT (identifier, algorithm) DO UPDATE SET
73
- private_jwk = excluded.private_jwk,
74
- public_jwk = excluded.public_jwk,
75
- created_at = excluded.created_at
50
+ passwordResetByHash: db.prepare('SELECT * FROM password_resets WHERE token_hash = ?'),
51
+ deletePasswordReset: db.prepare('DELETE FROM password_resets WHERE token_hash = ?'),
52
+ deletePasswordResetsForUser: db.prepare('DELETE FROM password_resets WHERE user_id = ?'),
53
+ prunePasswordResets: db.prepare('DELETE FROM password_resets WHERE expires_at <= ?'),
54
+ spendToken: db.prepare(`
55
+ INSERT OR IGNORE INTO spent_tokens (token_hash, spent_at, expires_at) VALUES (?, ?, ?)
56
+ `),
57
+ pruneSpentTokens: db.prepare('DELETE FROM spent_tokens WHERE expires_at <= ?'),
58
+ getState: db.prepare('SELECT value FROM cms_state WHERE key = ?'),
59
+ putState: db.prepare(`
60
+ INSERT INTO cms_state (key, value, updated_at) VALUES (?, ?, ?)
61
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
76
62
  `),
77
- countFollowers: db.prepare('SELECT COUNT(*) AS count FROM followers'),
63
+ // `?1 IS NULL` is how one statement answers both questions: naming a user
64
+ // counts theirs, naming nobody counts the site's.
65
+ countFollowers: db.prepare('SELECT COUNT(*) AS count FROM followers WHERE ?1 IS NULL OR username = ?1'),
78
66
  listFollowers: db.prepare(`
79
67
  SELECT * FROM followers
68
+ WHERE ?1 IS NULL OR username = ?1
80
69
  ORDER BY followed_at DESC, actor_id DESC
81
- LIMIT ? OFFSET ?
70
+ LIMIT ?2 OFFSET ?3
82
71
  `),
83
- followerById: db.prepare('SELECT * FROM followers WHERE actor_id = ?'),
72
+ followerById: db.prepare('SELECT * FROM followers WHERE username = ? AND actor_id = ?'),
84
73
  putFollower: db.prepare(`
85
74
  INSERT INTO followers (
86
- actor_id, inbox_id, shared_inbox_id, handle, name, icon_url, url, followed_at
87
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
88
- ON CONFLICT (actor_id) DO UPDATE SET
75
+ username, actor_id, inbox_id, shared_inbox_id, handle, name, icon_url, url, followed_at
76
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
77
+ ON CONFLICT (username, actor_id) DO UPDATE SET
89
78
  inbox_id = excluded.inbox_id,
90
79
  shared_inbox_id = excluded.shared_inbox_id,
91
80
  handle = excluded.handle,
@@ -93,7 +82,10 @@ export function openAdminStore(options) {
93
82
  icon_url = excluded.icon_url,
94
83
  url = excluded.url
95
84
  `),
96
- deleteFollower: db.prepare('DELETE FROM followers WHERE actor_id = ?'),
85
+ deleteFollower: db.prepare('DELETE FROM followers WHERE username = ? AND actor_id = ?'),
86
+ clearFollowers: db.prepare('DELETE FROM followers'),
87
+ clearInboxActivities: db.prepare('DELETE FROM ap_inbox'),
88
+ resetInboxSequence: db.prepare("DELETE FROM sqlite_sequence WHERE name = 'ap_inbox'"),
97
89
  countInboxActivities: db.prepare('SELECT COUNT(*) AS count FROM ap_inbox'),
98
90
  listInboxActivities: db.prepare(`
99
91
  SELECT * FROM ap_inbox
@@ -101,37 +93,80 @@ export function openAdminStore(options) {
101
93
  LIMIT ? OFFSET ?
102
94
  `),
103
95
  logInboxActivity: db.prepare(`
104
- INSERT INTO ap_inbox (activity_id, activity_type, actor_id, object_id, received_at, json)
105
- VALUES (?, ?, ?, ?, ?, ?)
96
+ INSERT INTO ap_inbox (
97
+ activity_id, activity_type, actor_id, object_id, in_reply_to, recipient, received_at, json
98
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
106
99
  ON CONFLICT (activity_id) DO UPDATE SET
107
100
  activity_type = excluded.activity_type,
108
101
  actor_id = excluded.actor_id,
109
102
  object_id = excluded.object_id,
103
+ in_reply_to = excluded.in_reply_to,
104
+ recipient = excluded.recipient,
110
105
  received_at = excluded.received_at,
111
106
  json = excluded.json
112
107
  RETURNING *
113
108
  `),
114
- countOutboundActivities: db.prepare('SELECT COUNT(*) AS count FROM ap_outbound'),
115
- listOutboundActivities: db.prepare(`
116
- SELECT * FROM ap_outbound
117
- ORDER BY created_at DESC, rowid DESC
109
+ countReplies: db.prepare(`SELECT COUNT(*) AS count FROM ap_inbox WHERE ${IS_REPLY}`),
110
+ listReplies: db.prepare(`
111
+ SELECT * FROM ap_inbox WHERE ${IS_REPLY}
112
+ ORDER BY received_at DESC, id DESC
118
113
  LIMIT ? OFFSET ?
119
114
  `),
120
- outboundActivityById: db.prepare('SELECT * FROM ap_outbound WHERE activity_id = ?'),
121
- putOutboundActivity: db.prepare(`
122
- INSERT INTO ap_outbound (activity_id, activity_type, object_id, slug, created_at, json)
123
- VALUES (?, ?, ?, ?, ?, ?)
124
- ON CONFLICT (activity_id) DO UPDATE SET
125
- activity_type = excluded.activity_type,
126
- object_id = excluded.object_id,
115
+ countRepliesTo: db.prepare(`
116
+ SELECT COUNT(*) AS count FROM ap_inbox WHERE ${IS_REPLY} AND in_reply_to = ?
117
+ `),
118
+ listRepliesTo: db.prepare(`
119
+ SELECT * FROM ap_inbox WHERE ${IS_REPLY} AND in_reply_to = ?
120
+ ORDER BY received_at DESC, id DESC
121
+ LIMIT ? OFFSET ?
122
+ `),
123
+ listCommentsFor: db.prepare(`
124
+ SELECT * FROM comments WHERE slug = ? ORDER BY submitted_at, id
125
+ `),
126
+ commentById: db.prepare('SELECT * FROM comments WHERE id = ?'),
127
+ countCommentsFor: db.prepare('SELECT COUNT(*) AS count FROM comments WHERE slug = ? AND status = ?'),
128
+ countCommentsByStatus: db.prepare('SELECT status, COUNT(*) AS count FROM comments GROUP BY status'),
129
+ hasApprovedAuthor: db.prepare(`
130
+ SELECT 1 FROM comments
131
+ WHERE status = 'approved' AND author_name = ? AND author_email IS ?
132
+ LIMIT 1
133
+ `),
134
+ putComment: db.prepare(`
135
+ INSERT INTO comments (
136
+ id, slug, permalink, source, kind, status,
137
+ author_name, author_url, author_email,
138
+ markdown, html, submitted_at, address_hash, in_reply_to, url, author_avatar, notify
139
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
140
+ ON CONFLICT (id) DO UPDATE SET
127
141
  slug = excluded.slug,
128
- json = excluded.json
129
- RETURNING *
142
+ permalink = excluded.permalink,
143
+ source = excluded.source,
144
+ kind = excluded.kind,
145
+ status = excluded.status,
146
+ author_name = excluded.author_name,
147
+ author_url = excluded.author_url,
148
+ author_email = excluded.author_email,
149
+ markdown = excluded.markdown,
150
+ html = excluded.html,
151
+ submitted_at = excluded.submitted_at,
152
+ address_hash = excluded.address_hash,
153
+ in_reply_to = excluded.in_reply_to,
154
+ url = excluded.url,
155
+ author_avatar = excluded.author_avatar,
156
+ notify = excluded.notify
130
157
  `),
158
+ deleteComment: db.prepare('DELETE FROM comments WHERE id = ?'),
159
+ clearComments: db.prepare('DELETE FROM comments'),
131
160
  recordDelivery: db.prepare(`
132
- INSERT INTO ap_deliveries (activity_id, actor_id, inbox_id, status, error, attempted_at)
133
- VALUES (?, ?, ?, ?, ?, ?)
161
+ INSERT INTO ap_deliveries (
162
+ activity_id, activity_type, object_id, slug,
163
+ actor_id, inbox_id, status, error, attempted_at
164
+ )
165
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
134
166
  ON CONFLICT (activity_id, actor_id) DO UPDATE SET
167
+ activity_type = excluded.activity_type,
168
+ object_id = excluded.object_id,
169
+ slug = excluded.slug,
135
170
  inbox_id = excluded.inbox_id,
136
171
  status = excluded.status,
137
172
  error = excluded.error,
@@ -144,8 +179,68 @@ export function openAdminStore(options) {
144
179
  countDeliveriesByStatus: db.prepare(`
145
180
  SELECT status, COUNT(*) AS count FROM ap_deliveries WHERE activity_id = ? GROUP BY status
146
181
  `),
182
+ lastDeliveryToInbox: db.prepare(`
183
+ SELECT * FROM ap_deliveries WHERE inbox_id = ?
184
+ ORDER BY attempted_at DESC, rowid DESC
185
+ LIMIT 1
186
+ `),
187
+ lastDeliveryToObject: db.prepare(`
188
+ SELECT * FROM ap_deliveries WHERE object_id = ?
189
+ ORDER BY attempted_at DESC, rowid DESC
190
+ LIMIT 1
191
+ `),
192
+ recordSentWebmention: db.prepare(`
193
+ INSERT INTO webmentions_sent (
194
+ slug, source, target, endpoint, status, error, attempted_at
195
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
196
+ ON CONFLICT (slug, target) DO UPDATE SET
197
+ source = excluded.source,
198
+ endpoint = excluded.endpoint,
199
+ status = excluded.status,
200
+ error = excluded.error,
201
+ attempted_at = excluded.attempted_at
202
+ RETURNING *
203
+ `),
204
+ listSentWebmentions: db.prepare('SELECT * FROM webmentions_sent WHERE slug = ? ORDER BY target'),
205
+ countSentWebmentionsByStatus: db.prepare(`
206
+ SELECT status, COUNT(*) AS count FROM webmentions_sent WHERE slug = ? GROUP BY status
207
+ `),
208
+ listRelays: db.prepare('SELECT * FROM ap_relays ORDER BY created_at, inbox_id'),
209
+ relayByInbox: db.prepare('SELECT * FROM ap_relays WHERE inbox_id = ?'),
210
+ relayByFollow: db.prepare('SELECT * FROM ap_relays WHERE follow_id = ?'),
211
+ putRelay: db.prepare(`
212
+ INSERT INTO ap_relays (
213
+ inbox_id, actor_id, state, reason, follow_id, created_at, updated_at
214
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
215
+ ON CONFLICT (inbox_id) DO UPDATE SET
216
+ actor_id = excluded.actor_id,
217
+ state = excluded.state,
218
+ reason = excluded.reason,
219
+ follow_id = excluded.follow_id,
220
+ updated_at = excluded.updated_at
221
+ RETURNING *
222
+ `),
223
+ deleteRelay: db.prepare('DELETE FROM ap_relays WHERE inbox_id = ?'),
147
224
  };
148
225
  let open = true;
226
+ /**
227
+ * Run a body with every write in it committed together, or none of them.
228
+ *
229
+ * The rebuilds are what need it: emptying an index and filling it again is
230
+ * one step to everything reading it, and a half-emptied `followers` is a
231
+ * site that has quietly stopped delivering to half its followers.
232
+ */
233
+ function inTransaction(body) {
234
+ db.exec('BEGIN');
235
+ try {
236
+ body();
237
+ db.exec('COMMIT');
238
+ }
239
+ catch (error) {
240
+ db.exec('ROLLBACK');
241
+ throw error;
242
+ }
243
+ }
149
244
  /**
150
245
  * The messages queued on a session. A column that will not parse is treated
151
246
  * as empty: a flash is a convenience, and losing one is better than a 500 on
@@ -164,81 +259,8 @@ export function openAdminStore(options) {
164
259
  return [];
165
260
  }
166
261
  }
167
- function storedUser(username) {
168
- const row = statements.userByName.get(username);
169
- if (row === undefined)
170
- return undefined;
171
- return {
172
- id: Number(row['id']),
173
- username: String(row['username']),
174
- passwordHash: String(row['password_hash']),
175
- createdAt: String(row['created_at']),
176
- };
177
- }
178
262
  return {
179
263
  file,
180
- countUsers() {
181
- const row = statements.countUsers.get();
182
- return Number(row?.['count'] ?? 0);
183
- },
184
- listUsers() {
185
- return statements.listUsers.all().map((row) => ({
186
- id: Number(row['id']),
187
- username: String(row['username']),
188
- createdAt: String(row['created_at']),
189
- }));
190
- },
191
- getUser(username) {
192
- return storedUser(username);
193
- },
194
- getUserById(id) {
195
- const row = statements.userById.get(id);
196
- if (row === undefined)
197
- return undefined;
198
- return {
199
- id: Number(row['id']),
200
- username: String(row['username']),
201
- createdAt: String(row['created_at']),
202
- };
203
- },
204
- createUser(input) {
205
- const createdAt = new Date().toISOString();
206
- const hash = hashPassword(input.password);
207
- try {
208
- statements.insertUser.run(input.username, hash, createdAt);
209
- }
210
- catch (error) {
211
- if (isUniqueViolation(error))
212
- throw new DuplicateUsernameError(input.username);
213
- throw error;
214
- }
215
- const created = storedUser(input.username);
216
- if (created === undefined) {
217
- throw new Error(`The user "${input.username}" vanished between insert and read.`);
218
- }
219
- return { id: created.id, username: created.username, createdAt: created.createdAt };
220
- },
221
- verifyPassword(username, password) {
222
- const user = storedUser(username);
223
- if (user === undefined) {
224
- // Hash anyway, so a missing user and a wrong password take the same
225
- // time and the login form does not become a user enumerator.
226
- verifyPasswordHash(DUMMY_HASH, password);
227
- return undefined;
228
- }
229
- if (!verifyPasswordHash(user.passwordHash, password))
230
- return undefined;
231
- return { id: user.id, username: user.username, createdAt: user.createdAt };
232
- },
233
- setPassword(userId, password) {
234
- // Hashed before the update rather than inside it, so a hash that throws
235
- // leaves the stored one alone.
236
- const hash = hashPassword(password);
237
- return statements.setPassword.run(hash, userId).changes > 0;
238
- },
239
- deleteUser(userId) {
240
- return statements.deleteUser.run(userId).changes > 0;
241
- },
242
264
  createSession(input) {
243
265
  const now = input.now ?? new Date();
244
266
  const session = {
@@ -271,52 +293,87 @@ export function openAdminStore(options) {
271
293
  // and spares nothing instead of matching nothing.
272
294
  return Number(statements.deleteSessionsForUser.run(userId, options.except ?? null).changes);
273
295
  },
274
- listActorKeys(identifier) {
275
- const rows = statements.actorKeys.all(identifier);
276
- const keys = rows
277
- .map((row) => ({
278
- identifier: String(row['identifier']),
279
- algorithm: String(row['algorithm']),
280
- privateJwk: String(row['private_jwk']),
281
- publicJwk: String(row['public_jwk']),
296
+ createPasswordReset(input) {
297
+ const now = input.now ?? new Date();
298
+ const token = randomToken();
299
+ const reset = {
300
+ token,
301
+ userId: input.userId,
302
+ createdAt: now.toISOString(),
303
+ expiresAt: new Date(now.getTime() + input.lifetimeSeconds * 1000).toISOString(),
304
+ };
305
+ statements.insertPasswordReset.run(hashToken(token), reset.userId, reset.createdAt, reset.expiresAt);
306
+ return reset;
307
+ },
308
+ getPasswordReset(token, now = new Date()) {
309
+ const hash = hashToken(token);
310
+ const row = statements.passwordResetByHash.get(hash);
311
+ if (row === undefined)
312
+ return undefined;
313
+ const reset = {
314
+ userId: Number(row['user_id']),
282
315
  createdAt: String(row['created_at']),
283
- }))
284
- // An algorithm this version has never heard of is a row a later
285
- // version wrote; ignoring it is better than handing Fedify a key it
286
- // cannot import.
287
- .filter((key) => ACTOR_KEY_ALGORITHMS.includes(key.algorithm));
288
- return keys.sort((a, b) => ACTOR_KEY_ALGORITHMS.indexOf(a.algorithm) - ACTOR_KEY_ALGORITHMS.indexOf(b.algorithm));
289
- },
290
- putActorKey(key) {
291
- const stored = { ...key, createdAt: new Date().toISOString() };
292
- statements.putActorKey.run(stored.identifier, stored.algorithm, stored.privateJwk, stored.publicJwk, stored.createdAt);
293
- return stored;
294
- },
295
- countFollowers() {
296
- const row = statements.countFollowers.get();
316
+ expiresAt: String(row['expires_at']),
317
+ };
318
+ // The same rule the sessions follow: an expired row is deleted rather
319
+ // than ignored, so a clock that goes backwards cannot revive a link.
320
+ if (reset.expiresAt <= now.toISOString()) {
321
+ statements.deletePasswordReset.run(hash);
322
+ return undefined;
323
+ }
324
+ return reset;
325
+ },
326
+ deletePasswordReset(token) {
327
+ return statements.deletePasswordReset.run(hashToken(token)).changes > 0;
328
+ },
329
+ deletePasswordResetsForUser(userId) {
330
+ return Number(statements.deletePasswordResetsForUser.run(userId).changes);
331
+ },
332
+ prunePasswordResets(now = new Date()) {
333
+ return Number(statements.prunePasswordResets.run(now.toISOString()).changes);
334
+ },
335
+ spendToken(token, expiresAt, now = new Date()) {
336
+ // `INSERT OR IGNORE` rather than a read then a write: two clicks on the
337
+ // same link arriving together must not both find nothing and both act.
338
+ const written = statements.spendToken.run(hashToken(token), now.toISOString(), expiresAt);
339
+ return Number(written.changes) > 0;
340
+ },
341
+ pruneSpentTokens(now = new Date()) {
342
+ return Number(statements.pruneSpentTokens.run(now.toISOString()).changes);
343
+ },
344
+ countFollowers(username) {
345
+ const row = statements.countFollowers.get(username ?? null);
297
346
  return Number(row?.['count'] ?? 0);
298
347
  },
299
- listFollowers(options = {}) {
300
- const rows = statements.listFollowers.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
348
+ listFollowers(username, options = {}) {
349
+ const rows = statements.listFollowers.all(username ?? null, options.limit ?? NO_LIMIT, options.offset ?? 0);
301
350
  return rows.map(toFollower);
302
351
  },
303
- getFollower(actorId) {
304
- const row = statements.followerById.get(actorId);
352
+ getFollower(username, actorId) {
353
+ const row = statements.followerById.get(username, actorId);
305
354
  return row === undefined ? undefined : toFollower(row);
306
355
  },
307
356
  putFollower(follower) {
308
- statements.putFollower.run(follower.actorId, follower.inboxId, follower.sharedInboxId, follower.handle, follower.name, follower.iconUrl, follower.url, follower.followedAt ?? new Date().toISOString());
357
+ statements.putFollower.run(follower.username, follower.actorId, follower.inboxId, follower.sharedInboxId, follower.handle, follower.name, follower.iconUrl, follower.url, follower.followedAt ?? new Date().toISOString());
309
358
  // Read back rather than returning what was written: the upsert leaves an
310
359
  // existing `followed_at` alone, so the row is the only thing that knows
311
360
  // when the follow really began.
312
- const row = statements.followerById.get(follower.actorId);
361
+ const row = statements.followerById.get(follower.username, follower.actorId);
313
362
  if (row === undefined) {
314
363
  throw new Error(`The follower "${follower.actorId}" vanished between insert and read.`);
315
364
  }
316
365
  return toFollower(row);
317
366
  },
318
- deleteFollower(actorId) {
319
- return statements.deleteFollower.run(actorId).changes > 0;
367
+ deleteFollower(username, actorId) {
368
+ return statements.deleteFollower.run(username, actorId).changes > 0;
369
+ },
370
+ replaceFollowers(followers) {
371
+ inTransaction(() => {
372
+ statements.clearFollowers.run();
373
+ for (const follower of followers) {
374
+ statements.putFollower.run(follower.username, follower.actorId, follower.inboxId, follower.sharedInboxId, follower.handle, follower.name, follower.iconUrl, follower.url, follower.followedAt ?? new Date().toISOString());
375
+ }
376
+ });
320
377
  },
321
378
  countInboxActivities() {
322
379
  const row = statements.countInboxActivities.get();
@@ -327,33 +384,110 @@ export function openAdminStore(options) {
327
384
  return rows.map(toInboxActivity);
328
385
  },
329
386
  logInboxActivity(activity) {
330
- const row = statements.logInboxActivity.get(activity.activityId, activity.activityType, activity.actorId, activity.objectId, new Date().toISOString(), activity.json);
387
+ const row = statements.logInboxActivity.get(activity.activityId, activity.activityType, activity.actorId, activity.objectId,
388
+ // Derived here rather than passed in, so the column cannot say
389
+ // something the stored activity does not.
390
+ replyTargetOf(activity.json), activity.recipient ?? null, activity.receivedAt ?? new Date().toISOString(), activity.json);
331
391
  if (row === undefined) {
332
392
  throw new Error(`The activity "${activity.activityType}" was not written to the log.`);
333
393
  }
334
394
  return toInboxActivity(row);
335
395
  },
336
- countOutboundActivities() {
337
- const row = statements.countOutboundActivities.get();
396
+ replaceInboxActivities(activities) {
397
+ inTransaction(() => {
398
+ statements.clearInboxActivities.run();
399
+ // The AUTOINCREMENT high-water mark goes with the rows, so the same
400
+ // log always produces the same ids however many times it is rebuilt.
401
+ statements.resetInboxSequence.run();
402
+ for (const activity of activities) {
403
+ statements.logInboxActivity.run(activity.activityId, activity.activityType, activity.actorId, activity.objectId, replyTargetOf(activity.json), activity.recipient ?? null, activity.receivedAt ?? new Date().toISOString(), activity.json);
404
+ }
405
+ });
406
+ },
407
+ countReplies() {
408
+ const row = statements.countReplies.get();
338
409
  return Number(row?.['count'] ?? 0);
339
410
  },
340
- listOutboundActivities(options = {}) {
341
- const rows = statements.listOutboundActivities.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
342
- return rows.map(toOutboundActivity);
411
+ listReplies(options = {}) {
412
+ const rows = statements.listReplies.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
413
+ return rows.map(toInboxActivity);
343
414
  },
344
- getOutboundActivity(activityId) {
345
- const row = statements.outboundActivityById.get(activityId);
346
- return row === undefined ? undefined : toOutboundActivity(row);
415
+ countRepliesTo(objectId) {
416
+ const row = statements.countRepliesTo.get(objectId);
417
+ return Number(row?.['count'] ?? 0);
347
418
  },
348
- putOutboundActivity(activity) {
349
- const row = statements.putOutboundActivity.get(activity.activityId, activity.activityType, activity.objectId, activity.slug, activity.createdAt ?? new Date().toISOString(), activity.json);
350
- if (row === undefined) {
351
- throw new Error(`The activity "${activity.activityId}" was not written to the outbox log.`);
419
+ listRepliesTo(objectId, options = {}) {
420
+ const rows = statements.listRepliesTo.all(objectId, options.limit ?? NO_LIMIT, options.offset ?? 0);
421
+ return rows.map(toInboxActivity);
422
+ },
423
+ listActivitiesAbout(objectIds) {
424
+ if (objectIds.length === 0)
425
+ return [];
426
+ // Prepared here rather than beside the others because the number of
427
+ // placeholders is the number of ids: a conversation asks about the post,
428
+ // then about the notes that answered it, and neither count is known when
429
+ // the statements are built.
430
+ const placeholders = objectIds.map(() => '?').join(', ');
431
+ const rows = db
432
+ .prepare(`SELECT * FROM ap_inbox
433
+ WHERE object_id IN (${placeholders}) OR in_reply_to IN (${placeholders})
434
+ ORDER BY id ASC`)
435
+ .all(...objectIds, ...objectIds);
436
+ return rows.map(toInboxActivity);
437
+ },
438
+ listCommentsFor(slug) {
439
+ return statements.listCommentsFor.all(slug).map(toComment);
440
+ },
441
+ listComments(options = {}) {
442
+ // Prepared here rather than beside the others because the `WHERE` is not
443
+ // fixed: the screen asks for one status and the site-wide feed asks for
444
+ // approved comments across every post.
445
+ const where = options.status === undefined ? '' : 'WHERE status = ?';
446
+ const parameters = options.status === undefined ? [] : [options.status];
447
+ const rows = db
448
+ .prepare(`SELECT * FROM comments ${where}
449
+ ORDER BY submitted_at DESC, id DESC
450
+ LIMIT ? OFFSET ?`)
451
+ .all(...parameters, options.limit ?? NO_LIMIT, options.offset ?? 0);
452
+ return rows.map(toComment);
453
+ },
454
+ countCommentsFor(slug, status) {
455
+ const row = statements.countCommentsFor.get(slug, status);
456
+ return Number(row?.['count'] ?? 0);
457
+ },
458
+ getComment(id) {
459
+ const row = statements.commentById.get(id);
460
+ return row === undefined ? undefined : toComment(row);
461
+ },
462
+ countCommentsByStatus() {
463
+ const counts = { pending: 0, approved: 0, spam: 0 };
464
+ for (const row of statements.countCommentsByStatus.all()) {
465
+ counts[commentStatus(row['status'])] += Number(row['count'] ?? 0);
352
466
  }
353
- return toOutboundActivity(row);
467
+ return counts;
468
+ },
469
+ hasApprovedAuthor(name, email) {
470
+ // `IS` rather than `=`, so an author who gave no email is matched by the
471
+ // rows that gave none either instead of matching nothing.
472
+ return statements.hasApprovedAuthor.get(name, email) !== undefined;
473
+ },
474
+ putComment(comment) {
475
+ statements.putComment.run(comment.id, comment.slug, comment.permalink, comment.source, comment.kind, comment.status, comment.author.name, comment.author.url, comment.author.email, comment.content.markdown, comment.content.html, comment.submitted, comment.addressHash, comment.inReplyTo, comment.url, comment.author.avatar, comment.notify ? 1 : 0);
476
+ return comment;
477
+ },
478
+ deleteComment(id) {
479
+ return statements.deleteComment.run(id).changes > 0;
480
+ },
481
+ replaceComments(comments) {
482
+ inTransaction(() => {
483
+ statements.clearComments.run();
484
+ for (const comment of comments) {
485
+ statements.putComment.run(comment.id, comment.slug, comment.permalink, comment.source, comment.kind, comment.status, comment.author.name, comment.author.url, comment.author.email, comment.content.markdown, comment.content.html, comment.submitted, comment.addressHash, comment.inReplyTo, comment.url, comment.author.avatar, comment.notify ? 1 : 0);
486
+ }
487
+ });
354
488
  },
355
489
  recordDelivery(delivery) {
356
- const row = statements.recordDelivery.get(delivery.activityId, delivery.actorId, delivery.inboxId, delivery.status, delivery.error, delivery.attemptedAt ?? new Date().toISOString());
490
+ const row = statements.recordDelivery.get(delivery.activityId, delivery.activityType, delivery.objectId, delivery.slug, delivery.actorId, delivery.inboxId, delivery.status, delivery.error, delivery.attemptedAt ?? new Date().toISOString());
357
491
  if (row === undefined) {
358
492
  throw new Error(`The delivery of "${delivery.activityId}" to "${delivery.actorId}" was not recorded.`);
359
493
  }
@@ -371,35 +505,108 @@ export function openAdminStore(options) {
371
505
  }
372
506
  return counts;
373
507
  },
374
- pruneSessions(now = new Date()) {
375
- return Number(statements.pruneSessions.run(now.toISOString()).changes);
508
+ lastDeliveryToInbox(inboxId) {
509
+ const row = statements.lastDeliveryToInbox.get(inboxId);
510
+ return row === undefined ? undefined : toDelivery(row);
376
511
  },
377
- countSettings() {
378
- const row = statements.countSettings.get();
379
- return Number(row?.['count'] ?? 0);
512
+ lastDeliveryToObject(objectId) {
513
+ const row = statements.lastDeliveryToObject.get(objectId);
514
+ return row === undefined ? undefined : toDelivery(row);
380
515
  },
381
- allSettings() {
382
- const settings = {};
383
- for (const row of statements.allSettings.all()) {
384
- settings[String(row['key'])] = String(row['value']);
516
+ recordSentWebmention(sent) {
517
+ const row = statements.recordSentWebmention.get(sent.slug, sent.source, sent.target, sent.endpoint, sent.status, sent.error, sent.attemptedAt ?? new Date().toISOString());
518
+ if (row === undefined) {
519
+ throw new Error(`The webmention to ${sent.target} could not be recorded.`);
385
520
  }
386
- return settings;
387
- },
388
- setSettings(values) {
389
- const updatedAt = new Date().toISOString();
390
- // One transaction, so a save that fails half way through leaves the
391
- // stored settings as they were rather than as a mixture of two versions.
392
- db.exec('BEGIN');
393
- try {
394
- for (const [key, value] of Object.entries(values)) {
395
- statements.putSetting.run(key, value, updatedAt);
396
- }
397
- db.exec('COMMIT');
521
+ return toSentWebmention(row);
522
+ },
523
+ listSentWebmentions(slug) {
524
+ const rows = statements.listSentWebmentions.all(slug);
525
+ return rows.map(toSentWebmention);
526
+ },
527
+ countSentWebmentionsByStatus(slug) {
528
+ const counts = { sent: 0, none: 0, failed: 0 };
529
+ const rows = statements.countSentWebmentionsByStatus.all(slug);
530
+ for (const row of rows) {
531
+ counts[webmentionSendStatus(row['status'])] = Number(row['count']);
398
532
  }
399
- catch (error) {
400
- db.exec('ROLLBACK');
401
- throw error;
533
+ return counts;
534
+ },
535
+ listRelays() {
536
+ return statements.listRelays.all().map(toRelay);
537
+ },
538
+ getRelay(inboxId) {
539
+ const row = statements.relayByInbox.get(inboxId);
540
+ return row === undefined ? undefined : toRelay(row);
541
+ },
542
+ getRelayByFollow(followId) {
543
+ const row = statements.relayByFollow.get(followId);
544
+ return row === undefined ? undefined : toRelay(row);
545
+ },
546
+ putRelay(relay) {
547
+ const now = new Date().toISOString();
548
+ const row = statements.putRelay.get(relay.inboxId, relay.actorId, relay.state, relay.reason, relay.followId, relay.createdAt ?? now, now);
549
+ if (row === undefined) {
550
+ throw new Error(`The relay subscription to "${relay.inboxId}" was not written.`);
402
551
  }
552
+ return toRelay(row);
553
+ },
554
+ deleteRelay(inboxId) {
555
+ return statements.deleteRelay.run(inboxId).changes > 0;
556
+ },
557
+ pruneSessions(now = new Date()) {
558
+ return Number(statements.pruneSessions.run(now.toISOString()).changes);
559
+ },
560
+ legacySettings() {
561
+ // Prepared here rather than with the rest, because the table this reads
562
+ // is one that will not be there: a statement over a missing table throws
563
+ // when it is prepared, which would take the whole store down.
564
+ if (!hasTable(db, 'settings'))
565
+ return undefined;
566
+ return db.prepare('SELECT key, value, updated_at FROM settings').all().map((row) => ({
567
+ key: String(row['key']),
568
+ value: String(row['value']),
569
+ updatedAt: String(row['updated_at']),
570
+ }));
571
+ },
572
+ legacyActorKeys() {
573
+ // Prepared here for the same reason `legacySettings` is: the table is one
574
+ // that will not be there, and a statement over a missing table throws
575
+ // when it is prepared rather than when it is run.
576
+ if (!hasTable(db, 'actor_keys'))
577
+ return undefined;
578
+ return db.prepare('SELECT identifier, algorithm, private_jwk FROM actor_keys').all().map((row) => ({
579
+ identifier: String(row['identifier']),
580
+ algorithm: String(row['algorithm']),
581
+ privateJwk: String(row['private_jwk']),
582
+ }));
583
+ },
584
+ legacyUsers() {
585
+ // Prepared here for the same reason `legacySettings` is: the table is
586
+ // one that will not be there, and a statement over a missing table
587
+ // throws when it is prepared rather than when it is run.
588
+ if (!hasTable(db, 'users'))
589
+ return undefined;
590
+ return db
591
+ .prepare('SELECT id, username, password_hash, created_at FROM users ORDER BY id')
592
+ .all().map((row) => ({
593
+ id: Number(row['id']),
594
+ username: String(row['username']),
595
+ passwordHash: String(row['password_hash']),
596
+ createdAt: String(row['created_at']),
597
+ }));
598
+ },
599
+ dropLegacyTable(name) {
600
+ // The name is never a caller's to invent: it is one of this file's own
601
+ // shipped table names, so quoting it is enough.
602
+ db.exec(`DROP TABLE IF EXISTS "${name.replace(/"/g, '""')}"`);
603
+ },
604
+ getState(key) {
605
+ const row = statements.getState.get(key);
606
+ return row === undefined ? undefined : String(row['value']);
607
+ },
608
+ setState(key, value) {
609
+ statements.putState.run(key, value, new Date().toISOString());
403
610
  },
404
611
  pushFlash(sessionId, entry) {
405
612
  const queued = [...readFlash(sessionId), entry];
@@ -420,11 +627,6 @@ export function openAdminStore(options) {
420
627
  },
421
628
  };
422
629
  }
423
- /**
424
- * A well-formed hash of a password nobody has, verified against when the
425
- * username is unknown so that both failures cost the same.
426
- */
427
- const DUMMY_HASH = hashPassword('a password no user has, hashed once so that verification is constant time');
428
630
  function isFlashMessage(value) {
429
631
  if (typeof value !== 'object' || value === null)
430
632
  return false;
@@ -449,6 +651,7 @@ function nullableText(value) {
449
651
  }
450
652
  function toFollower(row) {
451
653
  return {
654
+ username: String(row['username']),
452
655
  actorId: String(row['actor_id']),
453
656
  inboxId: String(row['inbox_id']),
454
657
  sharedInboxId: nullableText(row['shared_inbox_id']),
@@ -466,23 +669,18 @@ function toInboxActivity(row) {
466
669
  activityType: String(row['activity_type']),
467
670
  actorId: String(row['actor_id']),
468
671
  objectId: nullableText(row['object_id']),
672
+ inReplyTo: nullableText(row['in_reply_to']),
673
+ recipient: nullableText(row['recipient']),
469
674
  receivedAt: String(row['received_at']),
470
675
  json: String(row['json']),
471
676
  };
472
677
  }
473
- function toOutboundActivity(row) {
678
+ function toDelivery(row) {
474
679
  return {
475
680
  activityId: String(row['activity_id']),
476
681
  activityType: String(row['activity_type']),
477
682
  objectId: String(row['object_id']),
478
683
  slug: nullableText(row['slug']),
479
- createdAt: String(row['created_at']),
480
- json: String(row['json']),
481
- };
482
- }
483
- function toDelivery(row) {
484
- return {
485
- activityId: String(row['activity_id']),
486
684
  actorId: String(row['actor_id']),
487
685
  inboxId: String(row['inbox_id']),
488
686
  status: deliveryStatus(row['status']),
@@ -490,6 +688,78 @@ function toDelivery(row) {
490
688
  attemptedAt: String(row['attempted_at']),
491
689
  };
492
690
  }
691
+ function toSentWebmention(row) {
692
+ return {
693
+ slug: String(row['slug']),
694
+ source: String(row['source']),
695
+ target: String(row['target']),
696
+ endpoint: nullableText(row['endpoint']),
697
+ status: webmentionSendStatus(row['status']),
698
+ error: nullableText(row['error']),
699
+ attemptedAt: String(row['attempted_at']),
700
+ };
701
+ }
702
+ /** A stored send status, or `failed` for one this version does not know. */
703
+ function webmentionSendStatus(value) {
704
+ return oneOf(value, WEBMENTION_SEND_STATUSES, 'failed');
705
+ }
706
+ function toComment(row) {
707
+ return {
708
+ id: String(row['id']),
709
+ slug: String(row['slug']),
710
+ permalink: String(row['permalink']),
711
+ source: oneOf(row['source'], COMMENT_SOURCES, 'comment'),
712
+ kind: oneOf(row['kind'], COMMENT_KINDS, 'reply'),
713
+ status: commentStatus(row['status']),
714
+ author: {
715
+ name: String(row['author_name']),
716
+ url: nullableText(row['author_url']),
717
+ email: nullableText(row['author_email']),
718
+ avatar: nullableText(row['author_avatar']),
719
+ },
720
+ content: { markdown: String(row['markdown']), html: String(row['html']) },
721
+ submitted: String(row['submitted_at']),
722
+ addressHash: nullableText(row['address_hash']),
723
+ inReplyTo: nullableText(row['in_reply_to']),
724
+ url: nullableText(row['url']),
725
+ notify: Number(row['notify'] ?? 0) === 1,
726
+ };
727
+ }
728
+ /**
729
+ * A stored comment status, or `pending` for one this version does not know.
730
+ *
731
+ * Pending is the safe reading: showing a comment nobody approved is the one
732
+ * mistake a moderation queue cannot make, and the screen is right there for
733
+ * the row.
734
+ */
735
+ function commentStatus(value) {
736
+ return oneOf(value, COMMENT_STATUSES, 'pending');
737
+ }
738
+ /** A stored enumeration value, or the fallback for one this version does not know. */
739
+ function oneOf(value, allowed, fallback) {
740
+ return allowed.includes(value) ? value : fallback;
741
+ }
742
+ function toRelay(row) {
743
+ return {
744
+ inboxId: String(row['inbox_id']),
745
+ actorId: nullableText(row['actor_id']),
746
+ state: relayState(row['state']),
747
+ reason: nullableText(row['reason']),
748
+ followId: nullableText(row['follow_id']),
749
+ createdAt: String(row['created_at']),
750
+ updatedAt: String(row['updated_at']),
751
+ };
752
+ }
753
+ /**
754
+ * A stored relay state, or `pending` for one this version does not know.
755
+ *
756
+ * Pending is the safe reading of an unknown state: a subscription this version
757
+ * cannot make sense of is one it should not be delivering public posts to, and
758
+ * Retry is on the screen for exactly that row.
759
+ */
760
+ function relayState(value) {
761
+ return RELAY_STATES.includes(value) ? value : 'pending';
762
+ }
493
763
  /**
494
764
  * A stored status, or `failed` for one this version does not know.
495
765
  *
@@ -510,13 +780,33 @@ function toSession(row) {
510
780
  expiresAt: String(row['expires_at']),
511
781
  };
512
782
  }
513
- /** 256 unguessable bits, hex encoded. Session ids and CSRF tokens are both this. */
783
+ /**
784
+ * 256 unguessable bits, hex encoded. Session ids, CSRF tokens and password
785
+ * reset tokens are all this.
786
+ */
514
787
  function randomToken() {
515
788
  return randomBytes(SESSION_ID_BYTES).toString('hex');
516
789
  }
517
- function isUniqueViolation(error) {
518
- return error instanceof Error && /UNIQUE constraint failed/i.test(error.message);
790
+ /**
791
+ * A reset token as the database keeps it: its SHA-256, hex encoded.
792
+ *
793
+ * Plain SHA-256 rather than argon2 on purpose. What argon2 buys is time
794
+ * against somebody guessing a password people chose, and there is nothing to
795
+ * guess here: the token is 256 bits out of `randomBytes`, so the only way to
796
+ * hold a valid one is to have been sent it. What the hash is for is that a
797
+ * copy of `geekity.db` — a backup, a stray file — is not a stack of working
798
+ * reset links.
799
+ */
800
+ function hashToken(token) {
801
+ return createHash('sha256').update(token, 'utf8').digest('hex');
802
+ }
803
+ /** Whether the database has a table by that name. */
804
+ function hasTable(db, name) {
805
+ return (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").all(name)
806
+ .length > 0);
519
807
  }
808
+ /** The table this store's applied versions are recorded in. */
809
+ const LEDGER = 'admin_migrations';
520
810
  /**
521
811
  * Schema versions for the admin tables, applied in order. They keep their own
522
812
  * ledger so their numbering never collides with the content index's.
@@ -525,6 +815,13 @@ function isUniqueViolation(error) {
525
815
  */
526
816
  const MIGRATIONS = [
527
817
  {
818
+ // Users and sessions, when SQLite still held both. decision-9 moved the
819
+ // accounts into `data/users.json`, so nothing reads the users table any
820
+ // more: `migrateUsersToFile` writes its rows out on the first boot of that
821
+ // version and drops it. The migration stays exactly as it shipped, because
822
+ // a shipped migration is never edited — which does mean a brand new
823
+ // database creates the table here and drops it a moment later. Migration
824
+ // 11 is where sessions lose the foreign key into it.
528
825
  version: 1,
529
826
  sql: `
530
827
  CREATE TABLE users (
@@ -557,10 +854,12 @@ const MIGRATIONS = [
557
854
  sql: `ALTER TABLE sessions ADD COLUMN flash TEXT`,
558
855
  },
559
856
  {
560
- // Site settings: doc-1's "data that lives only in SQLite", mirrored to
561
- // content/_data/site.json on every save. Key and value rather than one
562
- // row per site, so the federation settings M3 adds cost a row rather than
563
- // a migration each.
857
+ // Site settings, when SQLite was still the source of them. decision-9
858
+ // moved them into content/_data/site.json, so nothing reads this table
859
+ // any more: `migrateSettingsToFile` writes its rows out on the first boot
860
+ // of that version and drops it. The migration stays exactly as it shipped,
861
+ // because a shipped migration is never edited — which does mean a brand
862
+ // new database creates the table here and drops it a moment later.
564
863
  version: 3,
565
864
  sql: `
566
865
  CREATE TABLE settings (
@@ -571,11 +870,12 @@ const MIGRATIONS = [
571
870
  `,
572
871
  },
573
872
  {
574
- // The site actor's signing keys (doc-4). They are generated on the first
575
- // boot that federates and never again: an actor whose key changes is an
576
- // actor every follower has to re-verify, so this is the one table in the
577
- // database that a site really cannot afford to lose. Keyed by identifier
578
- // rather than by handle, so renaming `@blog@example.com` keeps the keys.
873
+ // The site actor's signing keys, when SQLite still held them. decision-9
874
+ // moved them into JWK files under `data/keys`, so nothing reads this table
875
+ // any more: `migrateActorKeysToFiles` writes its rows out on the first boot
876
+ // of that version and drops it. The migration stays exactly as it shipped,
877
+ // because a shipped migration is never edited — which does mean a brand new
878
+ // database creates the table here and drops it a moment later.
579
879
  version: 4,
580
880
  sql: `
581
881
  CREATE TABLE actor_keys (
@@ -673,32 +973,327 @@ const MIGRATIONS = [
673
973
  CREATE INDEX ap_deliveries_status ON ap_deliveries (status);
674
974
  `,
675
975
  },
976
+ {
977
+ // What a logged reply answers, so a post's comments can be counted and
978
+ // listed without reading every activity the site was ever sent.
979
+ //
980
+ // It is an index of the JSON beside it rather than a fact of its own: the
981
+ // backfill runs the very function the writer runs, so a database rebuilt
982
+ // from the inbox log (TASK-32) holds what this one holds.
983
+ version: 8,
984
+ sql: `
985
+ ALTER TABLE ap_inbox ADD COLUMN in_reply_to TEXT;
986
+ CREATE INDEX ap_inbox_in_reply_to ON ap_inbox (in_reply_to);
987
+ `,
988
+ run(db) {
989
+ const rows = db.prepare('SELECT id, json FROM ap_inbox').all();
990
+ const update = db.prepare('UPDATE ap_inbox SET in_reply_to = ? WHERE id = ?');
991
+ for (const row of rows) {
992
+ update.run(replyTargetOf(String(row['json'])), Number(row['id']));
993
+ }
994
+ },
995
+ },
996
+ {
997
+ // Relay subscriptions (FEP-ae0c, TASK-40). The list of relays is a
998
+ // setting mirrored to site.json; this is where each handshake stands,
999
+ // which is operational state and derivable again from the file — a relay
1000
+ // in the list with no row here is simply followed again on the next boot.
1001
+ //
1002
+ // Keyed by the inbox rather than by the relay's actor id, because the
1003
+ // inbox is the only thing known when the `Follow` goes out: the actor id
1004
+ // arrives with the `Accept`, days later if a human has to approve it.
1005
+ version: 9,
1006
+ sql: `
1007
+ CREATE TABLE ap_relays (
1008
+ inbox_id TEXT PRIMARY KEY,
1009
+ actor_id TEXT,
1010
+ state TEXT NOT NULL,
1011
+ reason TEXT,
1012
+ follow_id TEXT,
1013
+ created_at TEXT NOT NULL,
1014
+ updated_at TEXT NOT NULL
1015
+ );
1016
+
1017
+ CREATE INDEX ap_relays_follow_id ON ap_relays (follow_id);
1018
+ CREATE INDEX ap_relays_actor_id ON ap_relays (actor_id);
1019
+ `,
1020
+ },
1021
+ {
1022
+ // The CMS's own operational state, as opposed to the site's settings. It
1023
+ // is a table of its own rather than more rows in `settings` because the
1024
+ // number of settings is what decides whether a site is seeded from
1025
+ // content/_data/site.json, and because nothing here belongs in that file.
1026
+ version: 10,
1027
+ sql: `
1028
+ CREATE TABLE cms_state (
1029
+ key TEXT PRIMARY KEY,
1030
+ value TEXT NOT NULL,
1031
+ updated_at TEXT NOT NULL
1032
+ );
1033
+ `,
1034
+ },
1035
+ {
1036
+ // Sessions without the foreign key into `users`, because there is no users
1037
+ // table any more: decision-9 moved the accounts into `data/users.json` and
1038
+ // `migrateUsersToFile` drops the table on the boot after this migration
1039
+ // runs. The constraint could not survive that either way — with
1040
+ // `foreign_keys` on, dropping the parent cascades every login away, and an
1041
+ // insert afterwards fails with "no such table: main.users" — and SQLite
1042
+ // cannot drop a constraint in place, so the table is rebuilt. Every row is
1043
+ // copied across, so a site upgrading keeps the logins it had. What the key
1044
+ // was doing is done in the admin guard instead: a session naming a user
1045
+ // the file no longer holds is not a session.
1046
+ version: 11,
1047
+ sql: `
1048
+ CREATE TABLE sessions_without_users (
1049
+ id TEXT PRIMARY KEY,
1050
+ user_id INTEGER,
1051
+ csrf_token TEXT NOT NULL,
1052
+ created_at TEXT NOT NULL,
1053
+ expires_at TEXT NOT NULL,
1054
+ flash TEXT
1055
+ );
1056
+
1057
+ INSERT INTO sessions_without_users (
1058
+ id, user_id, csrf_token, created_at, expires_at, flash
1059
+ )
1060
+ SELECT id, user_id, csrf_token, created_at, expires_at, flash FROM sessions;
1061
+
1062
+ DROP TABLE sessions;
1063
+ ALTER TABLE sessions_without_users RENAME TO sessions;
1064
+
1065
+ CREATE INDEX sessions_expires_at ON sessions (expires_at);
1066
+ CREATE INDEX sessions_user_id ON sessions (user_id);
1067
+ `,
1068
+ },
1069
+ {
1070
+ // The outcomes without the payloads. decision-9 replaces "send this stored
1071
+ // activity again" with "send this post as it now reads", so nothing needs
1072
+ // the JSON-LD any more and `ap_outbound` goes: an activity rebuilt from
1073
+ // the file is the current answer, and a copy of what went out last time
1074
+ // could only ever be a staler one.
1075
+ //
1076
+ // What that table held about an activity that an outcome cannot do
1077
+ // without — its type, the object it was about and the slug — moves onto
1078
+ // the outcome row, which is now the whole of what SQLite remembers about
1079
+ // anything the site has sent. The foreign key has to go with the parent,
1080
+ // and SQLite cannot drop a constraint in place, so `ap_deliveries` is
1081
+ // rebuilt the way migration 11 rebuilt `sessions`. Every row that has an
1082
+ // activity to name is carried across as a courtesy: this table is a cache
1083
+ // and is allowed to be empty, so a row whose activity is somehow missing
1084
+ // is dropped rather than invented a type for.
1085
+ version: 12,
1086
+ sql: `
1087
+ CREATE TABLE ap_deliveries_standalone (
1088
+ activity_id TEXT NOT NULL,
1089
+ activity_type TEXT NOT NULL,
1090
+ object_id TEXT NOT NULL,
1091
+ slug TEXT,
1092
+ actor_id TEXT NOT NULL,
1093
+ inbox_id TEXT NOT NULL,
1094
+ status TEXT NOT NULL,
1095
+ error TEXT,
1096
+ attempted_at TEXT NOT NULL,
1097
+ PRIMARY KEY (activity_id, actor_id)
1098
+ );
1099
+
1100
+ INSERT INTO ap_deliveries_standalone (
1101
+ activity_id, activity_type, object_id, slug,
1102
+ actor_id, inbox_id, status, error, attempted_at
1103
+ )
1104
+ SELECT
1105
+ ap_deliveries.activity_id,
1106
+ ap_outbound.activity_type,
1107
+ ap_outbound.object_id,
1108
+ ap_outbound.slug,
1109
+ ap_deliveries.actor_id,
1110
+ ap_deliveries.inbox_id,
1111
+ ap_deliveries.status,
1112
+ ap_deliveries.error,
1113
+ ap_deliveries.attempted_at
1114
+ FROM ap_deliveries
1115
+ JOIN ap_outbound ON ap_outbound.activity_id = ap_deliveries.activity_id;
1116
+
1117
+ DROP TABLE ap_deliveries;
1118
+ ALTER TABLE ap_deliveries_standalone RENAME TO ap_deliveries;
1119
+
1120
+ CREATE INDEX ap_deliveries_attempted_at ON ap_deliveries (attempted_at);
1121
+ CREATE INDEX ap_deliveries_status ON ap_deliveries (status);
1122
+ CREATE INDEX ap_deliveries_object_id ON ap_deliveries (object_id, attempted_at);
1123
+
1124
+ DROP TABLE ap_outbound;
1125
+ `,
1126
+ },
1127
+ {
1128
+ // What a post's conversation is read by (TASK-49). `in_reply_to` already
1129
+ // had an index because a post's replies are counted on every feed; the
1130
+ // object an activity is about had none, and that is the column a like, a
1131
+ // boost, a `Delete` of a reply and an `Undo` of a like are all found by.
1132
+ version: 13,
1133
+ sql: `
1134
+ CREATE INDEX ap_inbox_object_id ON ap_inbox (object_id);
1135
+ `,
1136
+ },
1137
+ {
1138
+ // Native comments (TASK-50), which are files under
1139
+ // `content/_data/comments/` and nothing else: this table is an index of
1140
+ // them, emptied and read back on every boot exactly as `followers` and
1141
+ // `ap_inbox` are. Nothing here is a fact the files do not carry.
1142
+ //
1143
+ // The id is the comment's own name rather than a row number, because it is
1144
+ // what a reply names, what a moderation form posts and what the page
1145
+ // anchors on — all three have to survive a rebuilt index. The author's
1146
+ // name and email are indexed together because that pair is the whole of
1147
+ // WordPress's auto-approval rule.
1148
+ version: 14,
1149
+ sql: `
1150
+ CREATE TABLE comments (
1151
+ id TEXT PRIMARY KEY,
1152
+ slug TEXT NOT NULL,
1153
+ permalink TEXT NOT NULL,
1154
+ source TEXT NOT NULL,
1155
+ kind TEXT NOT NULL,
1156
+ status TEXT NOT NULL,
1157
+ author_name TEXT NOT NULL,
1158
+ author_url TEXT,
1159
+ author_email TEXT,
1160
+ markdown TEXT NOT NULL,
1161
+ html TEXT NOT NULL,
1162
+ submitted_at TEXT NOT NULL,
1163
+ address_hash TEXT,
1164
+ in_reply_to TEXT
1165
+ );
1166
+
1167
+ CREATE INDEX comments_slug ON comments (slug, submitted_at);
1168
+ CREATE INDEX comments_status ON comments (status, submitted_at);
1169
+ CREATE INDEX comments_author ON comments (author_name, author_email, status);
1170
+ `,
1171
+ },
1172
+ {
1173
+ // Webmentions (TASK-51), which are two different things and so two changes.
1174
+ //
1175
+ // A comment may now say where it lives, because a webmention lives on
1176
+ // somebody else's page: it is what a later webmention from that page is
1177
+ // recognised by, and what the thread's permalink points at for an entry
1178
+ // this site did not host. Its author may now have a face, out of the source
1179
+ // page's `h-card`. Both columns are an index of the comment files like
1180
+ // every other column of this table, so nothing backfills them — the rebuild
1181
+ // on the next boot does.
1182
+ //
1183
+ // What the site has sent is the other half, and it is a cache of outcomes
1184
+ // exactly as `ap_deliveries` is: no payload, one row per (post, target),
1185
+ // and a resend moves the row rather than adding to it. It is a table of
1186
+ // its own rather than a use of `ap_deliveries` because the federation
1187
+ // screen counts that table per activity, and a webmention is not one.
1188
+ version: 15,
1189
+ sql: `
1190
+ ALTER TABLE comments ADD COLUMN url TEXT;
1191
+ ALTER TABLE comments ADD COLUMN author_avatar TEXT;
1192
+
1193
+ CREATE TABLE webmentions_sent (
1194
+ slug TEXT NOT NULL,
1195
+ source TEXT NOT NULL,
1196
+ target TEXT NOT NULL,
1197
+ endpoint TEXT,
1198
+ status TEXT NOT NULL,
1199
+ error TEXT,
1200
+ attempted_at TEXT NOT NULL,
1201
+ PRIMARY KEY (slug, target)
1202
+ );
1203
+
1204
+ CREATE INDEX webmentions_sent_attempted_at ON webmentions_sent (attempted_at);
1205
+ `,
1206
+ },
1207
+ {
1208
+ // Password reset tokens (TASK-54). They sit beside the sessions, and for
1209
+ // the same reason: a reset link is a second, shorter-lived way of proving
1210
+ // you are somebody, and it is a cache — losing one costs the person
1211
+ // nothing but a second click on Forgot password. So `data/users.json`
1212
+ // stays the whole of what a site must back up, and a restart, a rebuild or
1213
+ // a `geekity rebuild` invalidates every link in flight, which is the safe
1214
+ // direction to fail in.
1215
+ //
1216
+ // The key is the token's SHA-256 rather than the token, so the database
1217
+ // holds nothing a working link could be rebuilt from: somebody who reads
1218
+ // this table — a backup, a stray copy of the file — learns that a reset
1219
+ // was asked for and nothing else. It is a plain hash rather than argon2
1220
+ // because the token is 256 random bits and there is no guess to slow down;
1221
+ // what argon2 buys on a password it cannot buy here.
1222
+ version: 16,
1223
+ sql: `
1224
+ CREATE TABLE password_resets (
1225
+ token_hash TEXT PRIMARY KEY,
1226
+ user_id INTEGER NOT NULL,
1227
+ created_at TEXT NOT NULL,
1228
+ expires_at TEXT NOT NULL
1229
+ );
1230
+
1231
+ CREATE INDEX password_resets_expires_at ON password_resets (expires_at);
1232
+ CREATE INDEX password_resets_user_id ON password_resets (user_id);
1233
+ `,
1234
+ },
1235
+ {
1236
+ // The two halves of a notification's one-click links (TASK-55).
1237
+ //
1238
+ // `spent_tokens` is what makes such a link single use. The link itself is
1239
+ // not stored — it is an HMAC-signed claim, so that a moderation link in an
1240
+ // inbox goes on working across a rebuilt or restored cache, which is a
1241
+ // thing decision-9 says a site may do whenever it likes. Only the fact
1242
+ // that one has been used is written down, keyed by its SHA-256 for the
1243
+ // reason `password_resets` is, and swept once the signature has expired.
1244
+ // Losing this table forgets which links were spent, and that costs a site
1245
+ // nothing: every action a link performs is idempotent, so doing it a
1246
+ // second time is doing it once.
1247
+ //
1248
+ // `comments.notify` is the commenter's "tell me about replies", which the
1249
+ // file says and this column indexes like every other column of that table.
1250
+ // Nothing backfills it: the rebuild on the next boot does, and a comment
1251
+ // written before this shipped never asked to be told.
1252
+ version: 17,
1253
+ sql: `
1254
+ CREATE TABLE spent_tokens (
1255
+ token_hash TEXT PRIMARY KEY,
1256
+ spent_at TEXT NOT NULL,
1257
+ expires_at TEXT NOT NULL
1258
+ );
1259
+
1260
+ CREATE INDEX spent_tokens_expires_at ON spent_tokens (expires_at);
1261
+
1262
+ ALTER TABLE comments ADD COLUMN notify INTEGER NOT NULL DEFAULT 0;
1263
+ `,
1264
+ },
1265
+ {
1266
+ // Followers belong to a user, and the inbox log says who was told
1267
+ // (decision-14, TASK-68). The site actor is gone, so there is nothing to
1268
+ // backfill: the `followers` table is rebuilt on every boot from the files
1269
+ // under `content/_data/federation/{username}/`, and the rows a site
1270
+ // carried for the site actor name nobody. The table is therefore dropped
1271
+ // and made again with the new key rather than altered — `actor_id` was its
1272
+ // primary key, and one actor may follow two of this site's users.
1273
+ version: 18,
1274
+ sql: `
1275
+ DROP TABLE followers;
1276
+
1277
+ CREATE TABLE followers (
1278
+ username TEXT NOT NULL,
1279
+ actor_id TEXT NOT NULL,
1280
+ inbox_id TEXT NOT NULL,
1281
+ shared_inbox_id TEXT,
1282
+ handle TEXT,
1283
+ name TEXT,
1284
+ icon_url TEXT,
1285
+ url TEXT,
1286
+ followed_at TEXT NOT NULL,
1287
+ PRIMARY KEY (username, actor_id)
1288
+ );
1289
+
1290
+ CREATE INDEX followers_followed_at ON followers (followed_at);
1291
+ CREATE INDEX followers_actor_id ON followers (actor_id);
1292
+
1293
+ ALTER TABLE ap_inbox ADD COLUMN recipient TEXT;
1294
+
1295
+ CREATE INDEX ap_inbox_recipient ON ap_inbox (recipient);
1296
+ `,
1297
+ },
676
1298
  ];
677
- function migrate(db) {
678
- db.exec(`
679
- CREATE TABLE IF NOT EXISTS admin_migrations (
680
- version INTEGER PRIMARY KEY,
681
- applied_at TEXT NOT NULL
682
- )
683
- `);
684
- const applied = new Set(db
685
- .prepare('SELECT version FROM admin_migrations')
686
- .all()
687
- .map((row) => Number(row['version'])));
688
- const record = db.prepare('INSERT INTO admin_migrations (version, applied_at) VALUES (?, ?)');
689
- for (const migration of MIGRATIONS) {
690
- if (applied.has(migration.version))
691
- continue;
692
- db.exec('BEGIN');
693
- try {
694
- db.exec(migration.sql);
695
- record.run(migration.version, new Date().toISOString());
696
- db.exec('COMMIT');
697
- }
698
- catch (error) {
699
- db.exec('ROLLBACK');
700
- throw error;
701
- }
702
- }
703
- }
704
1299
  //# sourceMappingURL=store.js.map