@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.
- package/LICENSE +21 -0
- package/README.md +870 -0
- package/admin/layouts/_flash.njk +5 -0
- package/admin/layouts/base.njk +27 -0
- package/admin/layouts/dashboard.njk +78 -0
- package/admin/layouts/document-conflict.njk +49 -0
- package/admin/layouts/document-editor.njk +167 -0
- package/admin/layouts/document-list.njk +92 -0
- package/admin/layouts/federation.njk +145 -0
- package/admin/layouts/login.njk +23 -0
- package/admin/layouts/placeholder.njk +10 -0
- package/admin/layouts/settings.njk +116 -0
- package/admin/layouts/setup.njk +27 -0
- package/admin/layouts/shell.njk +37 -0
- package/admin/layouts/users.njk +91 -0
- package/admin/static/admin.css +726 -0
- package/admin/static/editor.js +30 -0
- package/dist/admin/assets.d.ts +26 -0
- package/dist/admin/assets.d.ts.map +1 -0
- package/dist/admin/assets.js +38 -0
- package/dist/admin/assets.js.map +1 -0
- package/dist/admin/credentials.d.ts +28 -0
- package/dist/admin/credentials.d.ts.map +1 -0
- package/dist/admin/credentials.js +39 -0
- package/dist/admin/credentials.js.map +1 -0
- package/dist/admin/documents.d.ts +134 -0
- package/dist/admin/documents.d.ts.map +1 -0
- package/dist/admin/documents.js +613 -0
- package/dist/admin/documents.js.map +1 -0
- package/dist/admin/federation.d.ts +194 -0
- package/dist/admin/federation.d.ts.map +1 -0
- package/dist/admin/federation.js +299 -0
- package/dist/admin/federation.js.map +1 -0
- package/dist/admin/flash.d.ts +24 -0
- package/dist/admin/flash.d.ts.map +1 -0
- package/dist/admin/flash.js +31 -0
- package/dist/admin/flash.js.map +1 -0
- package/dist/admin/index.d.ts +23 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +15 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/passwords.d.ts +49 -0
- package/dist/admin/passwords.d.ts.map +1 -0
- package/dist/admin/passwords.js +122 -0
- package/dist/admin/passwords.js.map +1 -0
- package/dist/admin/preview.d.ts +18 -0
- package/dist/admin/preview.d.ts.map +1 -0
- package/dist/admin/preview.js +83 -0
- package/dist/admin/preview.js.map +1 -0
- package/dist/admin/routes.d.ts +54 -0
- package/dist/admin/routes.d.ts.map +1 -0
- package/dist/admin/routes.js +383 -0
- package/dist/admin/routes.js.map +1 -0
- package/dist/admin/session.d.ts +44 -0
- package/dist/admin/session.d.ts.map +1 -0
- package/dist/admin/session.js +69 -0
- package/dist/admin/session.js.map +1 -0
- package/dist/admin/settings.d.ts +194 -0
- package/dist/admin/settings.d.ts.map +1 -0
- package/dist/admin/settings.js +470 -0
- package/dist/admin/settings.js.map +1 -0
- package/dist/admin/store.d.ts +407 -0
- package/dist/admin/store.d.ts.map +1 -0
- package/dist/admin/store.js +704 -0
- package/dist/admin/store.js.map +1 -0
- package/dist/admin/templates.d.ts +36 -0
- package/dist/admin/templates.d.ts.map +1 -0
- package/dist/admin/templates.js +43 -0
- package/dist/admin/templates.js.map +1 -0
- package/dist/admin/uploads.d.ts +96 -0
- package/dist/admin/uploads.d.ts.map +1 -0
- package/dist/admin/uploads.js +179 -0
- package/dist/admin/uploads.js.map +1 -0
- package/dist/admin/users.d.ts +95 -0
- package/dist/admin/users.d.ts.map +1 -0
- package/dist/admin/users.js +233 -0
- package/dist/admin/users.js.map +1 -0
- package/dist/cli.d.ts +85 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +424 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +172 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +184 -0
- package/dist/config.js.map +1 -0
- package/dist/content/document.d.ts +66 -0
- package/dist/content/document.d.ts.map +1 -0
- package/dist/content/document.js +13 -0
- package/dist/content/document.js.map +1 -0
- package/dist/content/index.d.ts +17 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +10 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/markdown.d.ts +3 -0
- package/dist/content/markdown.d.ts.map +1 -0
- package/dist/content/markdown.js +39 -0
- package/dist/content/markdown.js.map +1 -0
- package/dist/content/media.d.ts +65 -0
- package/dist/content/media.d.ts.map +1 -0
- package/dist/content/media.js +143 -0
- package/dist/content/media.js.map +1 -0
- package/dist/content/parser.d.ts +24 -0
- package/dist/content/parser.d.ts.map +1 -0
- package/dist/content/parser.js +165 -0
- package/dist/content/parser.js.map +1 -0
- package/dist/content/save.d.ts +60 -0
- package/dist/content/save.d.ts.map +1 -0
- package/dist/content/save.js +103 -0
- package/dist/content/save.js.map +1 -0
- package/dist/content/slug.d.ts +25 -0
- package/dist/content/slug.d.ts.map +1 -0
- package/dist/content/slug.js +75 -0
- package/dist/content/slug.js.map +1 -0
- package/dist/content/store.d.ts +133 -0
- package/dist/content/store.d.ts.map +1 -0
- package/dist/content/store.js +411 -0
- package/dist/content/store.js.map +1 -0
- package/dist/content/sync.d.ts +146 -0
- package/dist/content/sync.d.ts.map +1 -0
- package/dist/content/sync.js +421 -0
- package/dist/content/sync.js.map +1 -0
- package/dist/content/writer.d.ts +37 -0
- package/dist/content/writer.d.ts.map +1 -0
- package/dist/content/writer.js +97 -0
- package/dist/content/writer.js.map +1 -0
- package/dist/env.d.ts +48 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -0
- package/dist/federation/actor.d.ts +55 -0
- package/dist/federation/actor.d.ts.map +1 -0
- package/dist/federation/actor.js +101 -0
- package/dist/federation/actor.js.map +1 -0
- package/dist/federation/article.d.ts +83 -0
- package/dist/federation/article.d.ts.map +1 -0
- package/dist/federation/article.js +169 -0
- package/dist/federation/article.js.map +1 -0
- package/dist/federation/delivery.d.ts +90 -0
- package/dist/federation/delivery.d.ts.map +1 -0
- package/dist/federation/delivery.js +289 -0
- package/dist/federation/delivery.js.map +1 -0
- package/dist/federation/federation.d.ts +75 -0
- package/dist/federation/federation.d.ts.map +1 -0
- package/dist/federation/federation.js +190 -0
- package/dist/federation/federation.js.map +1 -0
- package/dist/federation/followers.d.ts +43 -0
- package/dist/federation/followers.d.ts.map +1 -0
- package/dist/federation/followers.js +69 -0
- package/dist/federation/followers.js.map +1 -0
- package/dist/federation/inbox.d.ts +68 -0
- package/dist/federation/inbox.d.ts.map +1 -0
- package/dist/federation/inbox.js +171 -0
- package/dist/federation/inbox.js.map +1 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +10 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/federation/keys.d.ts +27 -0
- package/dist/federation/keys.d.ts.map +1 -0
- package/dist/federation/keys.js +60 -0
- package/dist/federation/keys.js.map +1 -0
- package/dist/federation/mount.d.ts +23 -0
- package/dist/federation/mount.d.ts.map +1 -0
- package/dist/federation/mount.js +71 -0
- package/dist/federation/mount.js.map +1 -0
- package/dist/federation/paths.d.ts +85 -0
- package/dist/federation/paths.d.ts.map +1 -0
- package/dist/federation/paths.js +97 -0
- package/dist/federation/paths.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +71 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +125 -0
- package/dist/init.js.map +1 -0
- package/dist/web/assets.d.ts +65 -0
- package/dist/web/assets.d.ts.map +1 -0
- package/dist/web/assets.js +152 -0
- package/dist/web/assets.js.map +1 -0
- package/dist/web/context.d.ts +121 -0
- package/dist/web/context.d.ts.map +1 -0
- package/dist/web/context.js +112 -0
- package/dist/web/context.js.map +1 -0
- package/dist/web/documents.d.ts +28 -0
- package/dist/web/documents.d.ts.map +1 -0
- package/dist/web/documents.js +39 -0
- package/dist/web/documents.js.map +1 -0
- package/dist/web/feeds.d.ts +122 -0
- package/dist/web/feeds.d.ts.map +1 -0
- package/dist/web/feeds.js +229 -0
- package/dist/web/feeds.js.map +1 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +10 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/negotiate.d.ts +219 -0
- package/dist/web/negotiate.d.ts.map +1 -0
- package/dist/web/negotiate.js +409 -0
- package/dist/web/negotiate.js.map +1 -0
- package/dist/web/pagination.d.ts +48 -0
- package/dist/web/pagination.d.ts.map +1 -0
- package/dist/web/pagination.js +30 -0
- package/dist/web/pagination.js.map +1 -0
- package/dist/web/render.d.ts +72 -0
- package/dist/web/render.d.ts.map +1 -0
- package/dist/web/render.js +73 -0
- package/dist/web/render.js.map +1 -0
- package/dist/web/routes.d.ts +31 -0
- package/dist/web/routes.d.ts.map +1 -0
- package/dist/web/routes.js +366 -0
- package/dist/web/routes.js.map +1 -0
- package/dist/web/templates.d.ts +34 -0
- package/dist/web/templates.d.ts.map +1 -0
- package/dist/web/templates.js +106 -0
- package/dist/web/templates.js.map +1 -0
- package/package.json +68 -0
- package/templates/site/content/_data/site.json +8 -0
- package/templates/site/content/pages/about.md +11 -0
- package/templates/site/content/pages/pages.json +3 -0
- package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
- package/templates/site/content/posts/posts.json +4 -0
- package/templates/site/geekity.config.ts +11 -0
- package/templates/site/gitignore +3 -0
- package/templates/site/server.ts +20 -0
- package/templates/site/tsconfig.json +18 -0
- package/themes/default/README.md +148 -0
- package/themes/default/layouts/404.njk +9 -0
- package/themes/default/layouts/base.njk +59 -0
- package/themes/default/layouts/home.njk +9 -0
- package/themes/default/layouts/page.njk +13 -0
- package/themes/default/layouts/post.njk +30 -0
- package/themes/default/layouts/tag.njk +19 -0
- package/themes/default/partials/pagination.njk +14 -0
- package/themes/default/partials/post-list.njk +24 -0
- package/themes/default/partials/tags.njk +12 -0
- 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
|