@geekity/cms 0.3.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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +870 -0
  3. package/admin/layouts/_flash.njk +5 -0
  4. package/admin/layouts/base.njk +27 -0
  5. package/admin/layouts/dashboard.njk +78 -0
  6. package/admin/layouts/document-conflict.njk +49 -0
  7. package/admin/layouts/document-editor.njk +167 -0
  8. package/admin/layouts/document-list.njk +92 -0
  9. package/admin/layouts/federation.njk +145 -0
  10. package/admin/layouts/login.njk +23 -0
  11. package/admin/layouts/placeholder.njk +10 -0
  12. package/admin/layouts/settings.njk +116 -0
  13. package/admin/layouts/setup.njk +27 -0
  14. package/admin/layouts/shell.njk +37 -0
  15. package/admin/layouts/users.njk +91 -0
  16. package/admin/static/admin.css +726 -0
  17. package/admin/static/editor.js +30 -0
  18. package/dist/admin/assets.d.ts +26 -0
  19. package/dist/admin/assets.d.ts.map +1 -0
  20. package/dist/admin/assets.js +38 -0
  21. package/dist/admin/assets.js.map +1 -0
  22. package/dist/admin/credentials.d.ts +28 -0
  23. package/dist/admin/credentials.d.ts.map +1 -0
  24. package/dist/admin/credentials.js +39 -0
  25. package/dist/admin/credentials.js.map +1 -0
  26. package/dist/admin/documents.d.ts +134 -0
  27. package/dist/admin/documents.d.ts.map +1 -0
  28. package/dist/admin/documents.js +613 -0
  29. package/dist/admin/documents.js.map +1 -0
  30. package/dist/admin/federation.d.ts +194 -0
  31. package/dist/admin/federation.d.ts.map +1 -0
  32. package/dist/admin/federation.js +299 -0
  33. package/dist/admin/federation.js.map +1 -0
  34. package/dist/admin/flash.d.ts +24 -0
  35. package/dist/admin/flash.d.ts.map +1 -0
  36. package/dist/admin/flash.js +31 -0
  37. package/dist/admin/flash.js.map +1 -0
  38. package/dist/admin/index.d.ts +23 -0
  39. package/dist/admin/index.d.ts.map +1 -0
  40. package/dist/admin/index.js +15 -0
  41. package/dist/admin/index.js.map +1 -0
  42. package/dist/admin/passwords.d.ts +49 -0
  43. package/dist/admin/passwords.d.ts.map +1 -0
  44. package/dist/admin/passwords.js +122 -0
  45. package/dist/admin/passwords.js.map +1 -0
  46. package/dist/admin/preview.d.ts +18 -0
  47. package/dist/admin/preview.d.ts.map +1 -0
  48. package/dist/admin/preview.js +83 -0
  49. package/dist/admin/preview.js.map +1 -0
  50. package/dist/admin/routes.d.ts +54 -0
  51. package/dist/admin/routes.d.ts.map +1 -0
  52. package/dist/admin/routes.js +383 -0
  53. package/dist/admin/routes.js.map +1 -0
  54. package/dist/admin/session.d.ts +44 -0
  55. package/dist/admin/session.d.ts.map +1 -0
  56. package/dist/admin/session.js +69 -0
  57. package/dist/admin/session.js.map +1 -0
  58. package/dist/admin/settings.d.ts +194 -0
  59. package/dist/admin/settings.d.ts.map +1 -0
  60. package/dist/admin/settings.js +470 -0
  61. package/dist/admin/settings.js.map +1 -0
  62. package/dist/admin/store.d.ts +407 -0
  63. package/dist/admin/store.d.ts.map +1 -0
  64. package/dist/admin/store.js +704 -0
  65. package/dist/admin/store.js.map +1 -0
  66. package/dist/admin/templates.d.ts +36 -0
  67. package/dist/admin/templates.d.ts.map +1 -0
  68. package/dist/admin/templates.js +43 -0
  69. package/dist/admin/templates.js.map +1 -0
  70. package/dist/admin/uploads.d.ts +96 -0
  71. package/dist/admin/uploads.d.ts.map +1 -0
  72. package/dist/admin/uploads.js +179 -0
  73. package/dist/admin/uploads.js.map +1 -0
  74. package/dist/admin/users.d.ts +95 -0
  75. package/dist/admin/users.d.ts.map +1 -0
  76. package/dist/admin/users.js +233 -0
  77. package/dist/admin/users.js.map +1 -0
  78. package/dist/cli.d.ts +85 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +424 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/config.d.ts +172 -0
  83. package/dist/config.d.ts.map +1 -0
  84. package/dist/config.js +184 -0
  85. package/dist/config.js.map +1 -0
  86. package/dist/content/document.d.ts +66 -0
  87. package/dist/content/document.d.ts.map +1 -0
  88. package/dist/content/document.js +13 -0
  89. package/dist/content/document.js.map +1 -0
  90. package/dist/content/index.d.ts +17 -0
  91. package/dist/content/index.d.ts.map +1 -0
  92. package/dist/content/index.js +10 -0
  93. package/dist/content/index.js.map +1 -0
  94. package/dist/content/markdown.d.ts +3 -0
  95. package/dist/content/markdown.d.ts.map +1 -0
  96. package/dist/content/markdown.js +39 -0
  97. package/dist/content/markdown.js.map +1 -0
  98. package/dist/content/media.d.ts +65 -0
  99. package/dist/content/media.d.ts.map +1 -0
  100. package/dist/content/media.js +143 -0
  101. package/dist/content/media.js.map +1 -0
  102. package/dist/content/parser.d.ts +24 -0
  103. package/dist/content/parser.d.ts.map +1 -0
  104. package/dist/content/parser.js +165 -0
  105. package/dist/content/parser.js.map +1 -0
  106. package/dist/content/save.d.ts +60 -0
  107. package/dist/content/save.d.ts.map +1 -0
  108. package/dist/content/save.js +103 -0
  109. package/dist/content/save.js.map +1 -0
  110. package/dist/content/slug.d.ts +25 -0
  111. package/dist/content/slug.d.ts.map +1 -0
  112. package/dist/content/slug.js +75 -0
  113. package/dist/content/slug.js.map +1 -0
  114. package/dist/content/store.d.ts +133 -0
  115. package/dist/content/store.d.ts.map +1 -0
  116. package/dist/content/store.js +411 -0
  117. package/dist/content/store.js.map +1 -0
  118. package/dist/content/sync.d.ts +146 -0
  119. package/dist/content/sync.d.ts.map +1 -0
  120. package/dist/content/sync.js +421 -0
  121. package/dist/content/sync.js.map +1 -0
  122. package/dist/content/writer.d.ts +37 -0
  123. package/dist/content/writer.d.ts.map +1 -0
  124. package/dist/content/writer.js +97 -0
  125. package/dist/content/writer.js.map +1 -0
  126. package/dist/env.d.ts +48 -0
  127. package/dist/env.d.ts.map +1 -0
  128. package/dist/env.js +2 -0
  129. package/dist/env.js.map +1 -0
  130. package/dist/federation/actor.d.ts +55 -0
  131. package/dist/federation/actor.d.ts.map +1 -0
  132. package/dist/federation/actor.js +101 -0
  133. package/dist/federation/actor.js.map +1 -0
  134. package/dist/federation/article.d.ts +83 -0
  135. package/dist/federation/article.d.ts.map +1 -0
  136. package/dist/federation/article.js +169 -0
  137. package/dist/federation/article.js.map +1 -0
  138. package/dist/federation/delivery.d.ts +90 -0
  139. package/dist/federation/delivery.d.ts.map +1 -0
  140. package/dist/federation/delivery.js +289 -0
  141. package/dist/federation/delivery.js.map +1 -0
  142. package/dist/federation/federation.d.ts +75 -0
  143. package/dist/federation/federation.d.ts.map +1 -0
  144. package/dist/federation/federation.js +190 -0
  145. package/dist/federation/federation.js.map +1 -0
  146. package/dist/federation/followers.d.ts +43 -0
  147. package/dist/federation/followers.d.ts.map +1 -0
  148. package/dist/federation/followers.js +69 -0
  149. package/dist/federation/followers.js.map +1 -0
  150. package/dist/federation/inbox.d.ts +68 -0
  151. package/dist/federation/inbox.d.ts.map +1 -0
  152. package/dist/federation/inbox.js +171 -0
  153. package/dist/federation/inbox.js.map +1 -0
  154. package/dist/federation/index.d.ts +14 -0
  155. package/dist/federation/index.d.ts.map +1 -0
  156. package/dist/federation/index.js +10 -0
  157. package/dist/federation/index.js.map +1 -0
  158. package/dist/federation/keys.d.ts +27 -0
  159. package/dist/federation/keys.d.ts.map +1 -0
  160. package/dist/federation/keys.js +60 -0
  161. package/dist/federation/keys.js.map +1 -0
  162. package/dist/federation/mount.d.ts +23 -0
  163. package/dist/federation/mount.d.ts.map +1 -0
  164. package/dist/federation/mount.js +71 -0
  165. package/dist/federation/mount.js.map +1 -0
  166. package/dist/federation/paths.d.ts +85 -0
  167. package/dist/federation/paths.d.ts.map +1 -0
  168. package/dist/federation/paths.js +97 -0
  169. package/dist/federation/paths.js.map +1 -0
  170. package/dist/index.d.ts +106 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +148 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/init.d.ts +71 -0
  175. package/dist/init.d.ts.map +1 -0
  176. package/dist/init.js +125 -0
  177. package/dist/init.js.map +1 -0
  178. package/dist/web/assets.d.ts +65 -0
  179. package/dist/web/assets.d.ts.map +1 -0
  180. package/dist/web/assets.js +152 -0
  181. package/dist/web/assets.js.map +1 -0
  182. package/dist/web/context.d.ts +121 -0
  183. package/dist/web/context.d.ts.map +1 -0
  184. package/dist/web/context.js +112 -0
  185. package/dist/web/context.js.map +1 -0
  186. package/dist/web/documents.d.ts +28 -0
  187. package/dist/web/documents.d.ts.map +1 -0
  188. package/dist/web/documents.js +39 -0
  189. package/dist/web/documents.js.map +1 -0
  190. package/dist/web/feeds.d.ts +122 -0
  191. package/dist/web/feeds.d.ts.map +1 -0
  192. package/dist/web/feeds.js +229 -0
  193. package/dist/web/feeds.js.map +1 -0
  194. package/dist/web/index.d.ts +17 -0
  195. package/dist/web/index.d.ts.map +1 -0
  196. package/dist/web/index.js +10 -0
  197. package/dist/web/index.js.map +1 -0
  198. package/dist/web/negotiate.d.ts +219 -0
  199. package/dist/web/negotiate.d.ts.map +1 -0
  200. package/dist/web/negotiate.js +409 -0
  201. package/dist/web/negotiate.js.map +1 -0
  202. package/dist/web/pagination.d.ts +48 -0
  203. package/dist/web/pagination.d.ts.map +1 -0
  204. package/dist/web/pagination.js +30 -0
  205. package/dist/web/pagination.js.map +1 -0
  206. package/dist/web/render.d.ts +72 -0
  207. package/dist/web/render.d.ts.map +1 -0
  208. package/dist/web/render.js +73 -0
  209. package/dist/web/render.js.map +1 -0
  210. package/dist/web/routes.d.ts +31 -0
  211. package/dist/web/routes.d.ts.map +1 -0
  212. package/dist/web/routes.js +366 -0
  213. package/dist/web/routes.js.map +1 -0
  214. package/dist/web/templates.d.ts +34 -0
  215. package/dist/web/templates.d.ts.map +1 -0
  216. package/dist/web/templates.js +106 -0
  217. package/dist/web/templates.js.map +1 -0
  218. package/package.json +68 -0
  219. package/templates/site/content/_data/site.json +8 -0
  220. package/templates/site/content/pages/about.md +11 -0
  221. package/templates/site/content/pages/pages.json +3 -0
  222. package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
  223. package/templates/site/content/posts/posts.json +4 -0
  224. package/templates/site/geekity.config.ts +11 -0
  225. package/templates/site/gitignore +3 -0
  226. package/templates/site/server.ts +20 -0
  227. package/templates/site/tsconfig.json +18 -0
  228. package/themes/default/README.md +148 -0
  229. package/themes/default/layouts/404.njk +9 -0
  230. package/themes/default/layouts/base.njk +59 -0
  231. package/themes/default/layouts/home.njk +9 -0
  232. package/themes/default/layouts/page.njk +13 -0
  233. package/themes/default/layouts/post.njk +30 -0
  234. package/themes/default/layouts/tag.njk +19 -0
  235. package/themes/default/partials/pagination.njk +14 -0
  236. package/themes/default/partials/post-list.njk +24 -0
  237. package/themes/default/partials/tags.njk +12 -0
  238. package/themes/default/static/style.css +327 -0
@@ -0,0 +1,704 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ 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;
9
+ /**
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.
16
+ */
17
+ export const ACTOR_KEY_ALGORITHMS = ['RSASSA-PKCS1-v1_5', 'Ed25519'];
18
+ /** How one delivery of one activity to one follower ended. */
19
+ 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
+ }
30
+ /**
31
+ * Open (and if needed create) the admin tables in `dataDir`, applying every
32
+ * migration the package ships. Applying them is idempotent.
33
+ *
34
+ * The connection is its own; SQLite in WAL mode is happy with the content
35
+ * index holding a second one on the same file.
36
+ */
37
+ 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);
44
+ 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
+ deleteSessionsForUser: db.prepare('DELETE FROM sessions WHERE user_id = ? AND id IS NOT ?'),
53
+ insertSession: db.prepare(`
54
+ INSERT INTO sessions (id, user_id, csrf_token, created_at, expires_at)
55
+ VALUES (?, ?, ?, ?, ?)
56
+ `),
57
+ sessionById: db.prepare('SELECT * FROM sessions WHERE id = ?'),
58
+ deleteSession: db.prepare('DELETE FROM sessions WHERE id = ?'),
59
+ pruneSessions: db.prepare('DELETE FROM sessions WHERE expires_at <= ?'),
60
+ readFlash: db.prepare('SELECT flash FROM sessions WHERE id = ?'),
61
+ 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
67
+ `),
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
76
+ `),
77
+ countFollowers: db.prepare('SELECT COUNT(*) AS count FROM followers'),
78
+ listFollowers: db.prepare(`
79
+ SELECT * FROM followers
80
+ ORDER BY followed_at DESC, actor_id DESC
81
+ LIMIT ? OFFSET ?
82
+ `),
83
+ followerById: db.prepare('SELECT * FROM followers WHERE actor_id = ?'),
84
+ putFollower: db.prepare(`
85
+ 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
89
+ inbox_id = excluded.inbox_id,
90
+ shared_inbox_id = excluded.shared_inbox_id,
91
+ handle = excluded.handle,
92
+ name = excluded.name,
93
+ icon_url = excluded.icon_url,
94
+ url = excluded.url
95
+ `),
96
+ deleteFollower: db.prepare('DELETE FROM followers WHERE actor_id = ?'),
97
+ countInboxActivities: db.prepare('SELECT COUNT(*) AS count FROM ap_inbox'),
98
+ listInboxActivities: db.prepare(`
99
+ SELECT * FROM ap_inbox
100
+ ORDER BY received_at DESC, id DESC
101
+ LIMIT ? OFFSET ?
102
+ `),
103
+ logInboxActivity: db.prepare(`
104
+ INSERT INTO ap_inbox (activity_id, activity_type, actor_id, object_id, received_at, json)
105
+ VALUES (?, ?, ?, ?, ?, ?)
106
+ ON CONFLICT (activity_id) DO UPDATE SET
107
+ activity_type = excluded.activity_type,
108
+ actor_id = excluded.actor_id,
109
+ object_id = excluded.object_id,
110
+ received_at = excluded.received_at,
111
+ json = excluded.json
112
+ RETURNING *
113
+ `),
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
118
+ LIMIT ? OFFSET ?
119
+ `),
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,
127
+ slug = excluded.slug,
128
+ json = excluded.json
129
+ RETURNING *
130
+ `),
131
+ recordDelivery: db.prepare(`
132
+ INSERT INTO ap_deliveries (activity_id, actor_id, inbox_id, status, error, attempted_at)
133
+ VALUES (?, ?, ?, ?, ?, ?)
134
+ ON CONFLICT (activity_id, actor_id) DO UPDATE SET
135
+ inbox_id = excluded.inbox_id,
136
+ status = excluded.status,
137
+ error = excluded.error,
138
+ attempted_at = excluded.attempted_at
139
+ RETURNING *
140
+ `),
141
+ listDeliveries: db.prepare(`
142
+ SELECT * FROM ap_deliveries WHERE activity_id = ? ORDER BY actor_id
143
+ `),
144
+ countDeliveriesByStatus: db.prepare(`
145
+ SELECT status, COUNT(*) AS count FROM ap_deliveries WHERE activity_id = ? GROUP BY status
146
+ `),
147
+ };
148
+ let open = true;
149
+ /**
150
+ * The messages queued on a session. A column that will not parse is treated
151
+ * as empty: a flash is a convenience, and losing one is better than a 500 on
152
+ * every admin page until the row is cleaned up by hand.
153
+ */
154
+ function readFlash(sessionId) {
155
+ const row = statements.readFlash.get(sessionId);
156
+ const raw = row?.['flash'];
157
+ if (typeof raw !== 'string' || raw === '')
158
+ return [];
159
+ try {
160
+ const parsed = JSON.parse(raw);
161
+ return Array.isArray(parsed) ? parsed.filter(isFlashMessage) : [];
162
+ }
163
+ catch {
164
+ return [];
165
+ }
166
+ }
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
+ return {
179
+ 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
+ createSession(input) {
243
+ const now = input.now ?? new Date();
244
+ const session = {
245
+ id: randomToken(),
246
+ userId: input.userId,
247
+ csrfToken: randomToken(),
248
+ createdAt: now.toISOString(),
249
+ expiresAt: new Date(now.getTime() + input.lifetimeSeconds * 1000).toISOString(),
250
+ };
251
+ statements.insertSession.run(session.id, session.userId, session.csrfToken, session.createdAt, session.expiresAt);
252
+ return session;
253
+ },
254
+ getSession(id, now = new Date()) {
255
+ const row = statements.sessionById.get(id);
256
+ if (row === undefined)
257
+ return undefined;
258
+ const session = toSession(row);
259
+ // ISO 8601 in UTC sorts the way time runs, so string order is time order.
260
+ if (session.expiresAt <= now.toISOString()) {
261
+ statements.deleteSession.run(id);
262
+ return undefined;
263
+ }
264
+ return session;
265
+ },
266
+ deleteSession(id) {
267
+ return statements.deleteSession.run(id).changes > 0;
268
+ },
269
+ deleteSessionsForUser(userId, options = {}) {
270
+ // `IS NOT` rather than `<>`, so a missing `except` compares against NULL
271
+ // and spares nothing instead of matching nothing.
272
+ return Number(statements.deleteSessionsForUser.run(userId, options.except ?? null).changes);
273
+ },
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']),
282
+ 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();
297
+ return Number(row?.['count'] ?? 0);
298
+ },
299
+ listFollowers(options = {}) {
300
+ const rows = statements.listFollowers.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
301
+ return rows.map(toFollower);
302
+ },
303
+ getFollower(actorId) {
304
+ const row = statements.followerById.get(actorId);
305
+ return row === undefined ? undefined : toFollower(row);
306
+ },
307
+ 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());
309
+ // Read back rather than returning what was written: the upsert leaves an
310
+ // existing `followed_at` alone, so the row is the only thing that knows
311
+ // when the follow really began.
312
+ const row = statements.followerById.get(follower.actorId);
313
+ if (row === undefined) {
314
+ throw new Error(`The follower "${follower.actorId}" vanished between insert and read.`);
315
+ }
316
+ return toFollower(row);
317
+ },
318
+ deleteFollower(actorId) {
319
+ return statements.deleteFollower.run(actorId).changes > 0;
320
+ },
321
+ countInboxActivities() {
322
+ const row = statements.countInboxActivities.get();
323
+ return Number(row?.['count'] ?? 0);
324
+ },
325
+ listInboxActivities(options = {}) {
326
+ const rows = statements.listInboxActivities.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
327
+ return rows.map(toInboxActivity);
328
+ },
329
+ logInboxActivity(activity) {
330
+ const row = statements.logInboxActivity.get(activity.activityId, activity.activityType, activity.actorId, activity.objectId, new Date().toISOString(), activity.json);
331
+ if (row === undefined) {
332
+ throw new Error(`The activity "${activity.activityType}" was not written to the log.`);
333
+ }
334
+ return toInboxActivity(row);
335
+ },
336
+ countOutboundActivities() {
337
+ const row = statements.countOutboundActivities.get();
338
+ return Number(row?.['count'] ?? 0);
339
+ },
340
+ listOutboundActivities(options = {}) {
341
+ const rows = statements.listOutboundActivities.all(options.limit ?? NO_LIMIT, options.offset ?? 0);
342
+ return rows.map(toOutboundActivity);
343
+ },
344
+ getOutboundActivity(activityId) {
345
+ const row = statements.outboundActivityById.get(activityId);
346
+ return row === undefined ? undefined : toOutboundActivity(row);
347
+ },
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.`);
352
+ }
353
+ return toOutboundActivity(row);
354
+ },
355
+ recordDelivery(delivery) {
356
+ const row = statements.recordDelivery.get(delivery.activityId, delivery.actorId, delivery.inboxId, delivery.status, delivery.error, delivery.attemptedAt ?? new Date().toISOString());
357
+ if (row === undefined) {
358
+ throw new Error(`The delivery of "${delivery.activityId}" to "${delivery.actorId}" was not recorded.`);
359
+ }
360
+ return toDelivery(row);
361
+ },
362
+ listDeliveries(activityId) {
363
+ const rows = statements.listDeliveries.all(activityId);
364
+ return rows.map(toDelivery);
365
+ },
366
+ countDeliveriesByStatus(activityId) {
367
+ const counts = { sent: 0, queued: 0, failed: 0 };
368
+ for (const row of statements.countDeliveriesByStatus.all(activityId)) {
369
+ const status = deliveryStatus(row['status']);
370
+ counts[status] += Number(row['count'] ?? 0);
371
+ }
372
+ return counts;
373
+ },
374
+ pruneSessions(now = new Date()) {
375
+ return Number(statements.pruneSessions.run(now.toISOString()).changes);
376
+ },
377
+ countSettings() {
378
+ const row = statements.countSettings.get();
379
+ return Number(row?.['count'] ?? 0);
380
+ },
381
+ allSettings() {
382
+ const settings = {};
383
+ for (const row of statements.allSettings.all()) {
384
+ settings[String(row['key'])] = String(row['value']);
385
+ }
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');
398
+ }
399
+ catch (error) {
400
+ db.exec('ROLLBACK');
401
+ throw error;
402
+ }
403
+ },
404
+ pushFlash(sessionId, entry) {
405
+ const queued = [...readFlash(sessionId), entry];
406
+ statements.writeFlash.run(JSON.stringify(queued), sessionId);
407
+ },
408
+ takeFlash(sessionId) {
409
+ const queued = readFlash(sessionId);
410
+ // The clear runs whether or not anything was queued; a row whose column
411
+ // is already null costs one write and stays simple.
412
+ statements.writeFlash.run(null, sessionId);
413
+ return queued;
414
+ },
415
+ close() {
416
+ if (!open)
417
+ return;
418
+ open = false;
419
+ db.close();
420
+ },
421
+ };
422
+ }
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
+ function isFlashMessage(value) {
429
+ if (typeof value !== 'object' || value === null)
430
+ return false;
431
+ const entry = value;
432
+ return ((entry['kind'] === 'notice' || entry['kind'] === 'error') &&
433
+ typeof entry['message'] === 'string');
434
+ }
435
+ /**
436
+ * What a `LIMIT` means when the caller named none. SQLite reads a negative
437
+ * limit as "every row", which is what an unpaged list asks for.
438
+ */
439
+ const NO_LIMIT = -1;
440
+ /**
441
+ * A `TEXT` column that may be `NULL`, as a string or `null`.
442
+ *
443
+ * Anything that is not text is read as `null` rather than stringified: every
444
+ * column this is used on is declared `TEXT`, so a value of another shape is a
445
+ * row somebody else wrote, and `[object Object]` is a worse answer than none.
446
+ */
447
+ function nullableText(value) {
448
+ return typeof value === 'string' ? value : null;
449
+ }
450
+ function toFollower(row) {
451
+ return {
452
+ actorId: String(row['actor_id']),
453
+ inboxId: String(row['inbox_id']),
454
+ sharedInboxId: nullableText(row['shared_inbox_id']),
455
+ handle: nullableText(row['handle']),
456
+ name: nullableText(row['name']),
457
+ iconUrl: nullableText(row['icon_url']),
458
+ url: nullableText(row['url']),
459
+ followedAt: String(row['followed_at']),
460
+ };
461
+ }
462
+ function toInboxActivity(row) {
463
+ return {
464
+ id: Number(row['id']),
465
+ activityId: nullableText(row['activity_id']),
466
+ activityType: String(row['activity_type']),
467
+ actorId: String(row['actor_id']),
468
+ objectId: nullableText(row['object_id']),
469
+ receivedAt: String(row['received_at']),
470
+ json: String(row['json']),
471
+ };
472
+ }
473
+ function toOutboundActivity(row) {
474
+ return {
475
+ activityId: String(row['activity_id']),
476
+ activityType: String(row['activity_type']),
477
+ objectId: String(row['object_id']),
478
+ 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
+ actorId: String(row['actor_id']),
487
+ inboxId: String(row['inbox_id']),
488
+ status: deliveryStatus(row['status']),
489
+ error: nullableText(row['error']),
490
+ attemptedAt: String(row['attempted_at']),
491
+ };
492
+ }
493
+ /**
494
+ * A stored status, or `failed` for one this version does not know.
495
+ *
496
+ * An unreadable status is reported as the pessimistic one: an admin screen
497
+ * showing a delivery as failed when it was not is a nuisance, and showing one
498
+ * as sent when nobody knows is a lie.
499
+ */
500
+ function deliveryStatus(value) {
501
+ return DELIVERY_STATUSES.includes(value) ? value : 'failed';
502
+ }
503
+ function toSession(row) {
504
+ const userId = row['user_id'];
505
+ return {
506
+ id: String(row['id']),
507
+ userId: userId === null || userId === undefined ? null : Number(userId),
508
+ csrfToken: String(row['csrf_token']),
509
+ createdAt: String(row['created_at']),
510
+ expiresAt: String(row['expires_at']),
511
+ };
512
+ }
513
+ /** 256 unguessable bits, hex encoded. Session ids and CSRF tokens are both this. */
514
+ function randomToken() {
515
+ return randomBytes(SESSION_ID_BYTES).toString('hex');
516
+ }
517
+ function isUniqueViolation(error) {
518
+ return error instanceof Error && /UNIQUE constraint failed/i.test(error.message);
519
+ }
520
+ /**
521
+ * Schema versions for the admin tables, applied in order. They keep their own
522
+ * ledger so their numbering never collides with the content index's.
523
+ *
524
+ * Never edit a migration that has shipped; append a new one.
525
+ */
526
+ const MIGRATIONS = [
527
+ {
528
+ version: 1,
529
+ sql: `
530
+ CREATE TABLE users (
531
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
532
+ username TEXT NOT NULL,
533
+ password_hash TEXT NOT NULL,
534
+ created_at TEXT NOT NULL
535
+ );
536
+
537
+ CREATE UNIQUE INDEX users_username ON users (username);
538
+
539
+ CREATE TABLE sessions (
540
+ id TEXT PRIMARY KEY,
541
+ user_id INTEGER REFERENCES users (id) ON DELETE CASCADE,
542
+ csrf_token TEXT NOT NULL,
543
+ created_at TEXT NOT NULL,
544
+ expires_at TEXT NOT NULL
545
+ );
546
+
547
+ CREATE INDEX sessions_expires_at ON sessions (expires_at);
548
+ CREATE INDEX sessions_user_id ON sessions (user_id);
549
+ `,
550
+ },
551
+ {
552
+ // Flash messages: a JSON array of {kind, message}, queued by the request
553
+ // that redirects and cleared by the one that renders them. They hang off
554
+ // the session rather than a cookie so they cannot be forged or replayed,
555
+ // and so they go when the session does.
556
+ version: 2,
557
+ sql: `ALTER TABLE sessions ADD COLUMN flash TEXT`,
558
+ },
559
+ {
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.
564
+ version: 3,
565
+ sql: `
566
+ CREATE TABLE settings (
567
+ key TEXT PRIMARY KEY,
568
+ value TEXT NOT NULL,
569
+ updated_at TEXT NOT NULL
570
+ );
571
+ `,
572
+ },
573
+ {
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.
579
+ version: 4,
580
+ sql: `
581
+ CREATE TABLE actor_keys (
582
+ identifier TEXT NOT NULL,
583
+ algorithm TEXT NOT NULL,
584
+ private_jwk TEXT NOT NULL,
585
+ public_jwk TEXT NOT NULL,
586
+ created_at TEXT NOT NULL,
587
+ PRIMARY KEY (identifier, algorithm)
588
+ );
589
+ `,
590
+ },
591
+ {
592
+ // The site's followers (doc-4). Fedify's KV store is a cache and may be
593
+ // thrown away; a follower may not, because an actor that followed and was
594
+ // forgotten simply stops hearing from the site. Keyed by actor id, which
595
+ // is what a `Follow`, an `Undo(Follow)` and an actor's own `Delete` all
596
+ // name, so all three find the same row.
597
+ version: 5,
598
+ sql: `
599
+ CREATE TABLE followers (
600
+ actor_id TEXT PRIMARY KEY,
601
+ inbox_id TEXT NOT NULL,
602
+ shared_inbox_id TEXT,
603
+ handle TEXT,
604
+ name TEXT,
605
+ icon_url TEXT,
606
+ url TEXT,
607
+ followed_at TEXT NOT NULL
608
+ );
609
+
610
+ CREATE INDEX followers_followed_at ON followers (followed_at);
611
+ `,
612
+ },
613
+ {
614
+ // Inbound activities that are recorded rather than acted on: likes,
615
+ // boosts and replies, which a later phase surfaces. The raw JSON-LD is
616
+ // kept because what that phase wants out of an activity is not knowable
617
+ // yet. The unique index on the activity id is what makes a redelivery an
618
+ // update rather than a second like; SQLite counts NULLs as distinct, so
619
+ // an activity that arrived without an id is always its own row.
620
+ version: 6,
621
+ sql: `
622
+ CREATE TABLE ap_inbox (
623
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
624
+ activity_id TEXT,
625
+ activity_type TEXT NOT NULL,
626
+ actor_id TEXT NOT NULL,
627
+ object_id TEXT,
628
+ received_at TEXT NOT NULL,
629
+ json TEXT NOT NULL
630
+ );
631
+
632
+ CREATE UNIQUE INDEX ap_inbox_activity_id ON ap_inbox (activity_id);
633
+ CREATE INDEX ap_inbox_received_at ON ap_inbox (received_at);
634
+ CREATE INDEX ap_inbox_actor_id ON ap_inbox (actor_id);
635
+ `,
636
+ },
637
+ {
638
+ // Outbound delivery (doc-4, decision-5). Two tables, because an activity
639
+ // is one thing and its fate at each follower is another: keeping the
640
+ // JSON-LD once rather than once per follower is what makes a redelivery to
641
+ // a thousand followers a single read, and one row per (activity, follower)
642
+ // is what lets the admin see which follower did not get it.
643
+ //
644
+ // The activity's own id is the key. It is derived from the post's object
645
+ // id — `#create`, `#update/{hash}`, `#delete/{time}` — so re-sending the
646
+ // same announcement updates the row it belongs to instead of writing a
647
+ // second one that says the same thing.
648
+ version: 7,
649
+ sql: `
650
+ CREATE TABLE ap_outbound (
651
+ activity_id TEXT PRIMARY KEY,
652
+ activity_type TEXT NOT NULL,
653
+ object_id TEXT NOT NULL,
654
+ slug TEXT,
655
+ created_at TEXT NOT NULL,
656
+ json TEXT NOT NULL
657
+ );
658
+
659
+ CREATE INDEX ap_outbound_created_at ON ap_outbound (created_at);
660
+ CREATE INDEX ap_outbound_object_id ON ap_outbound (object_id);
661
+
662
+ CREATE TABLE ap_deliveries (
663
+ activity_id TEXT NOT NULL REFERENCES ap_outbound (activity_id) ON DELETE CASCADE,
664
+ actor_id TEXT NOT NULL,
665
+ inbox_id TEXT NOT NULL,
666
+ status TEXT NOT NULL,
667
+ error TEXT,
668
+ attempted_at TEXT NOT NULL,
669
+ PRIMARY KEY (activity_id, actor_id)
670
+ );
671
+
672
+ CREATE INDEX ap_deliveries_attempted_at ON ap_deliveries (attempted_at);
673
+ CREATE INDEX ap_deliveries_status ON ap_deliveries (status);
674
+ `,
675
+ },
676
+ ];
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
+ //# sourceMappingURL=store.js.map