@evanp/activitypub-bot 0.8.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 (67) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/main-docker.yml +45 -0
  3. package/.github/workflows/tag-docker.yml +54 -0
  4. package/Dockerfile +23 -0
  5. package/LICENSE +661 -0
  6. package/README.md +82 -0
  7. package/bots/index.js +7 -0
  8. package/docs/activitypub.bot.drawio +110 -0
  9. package/index.js +23 -0
  10. package/lib/activitydistributor.js +263 -0
  11. package/lib/activityhandler.js +999 -0
  12. package/lib/activitypubclient.js +126 -0
  13. package/lib/activitystreams.js +41 -0
  14. package/lib/actorstorage.js +300 -0
  15. package/lib/app.js +173 -0
  16. package/lib/authorizer.js +133 -0
  17. package/lib/bot.js +44 -0
  18. package/lib/botcontext.js +520 -0
  19. package/lib/botdatastorage.js +87 -0
  20. package/lib/bots/donothing.js +11 -0
  21. package/lib/bots/ok.js +41 -0
  22. package/lib/digester.js +23 -0
  23. package/lib/httpsignature.js +195 -0
  24. package/lib/httpsignatureauthenticator.js +81 -0
  25. package/lib/keystorage.js +113 -0
  26. package/lib/microsyntax.js +140 -0
  27. package/lib/objectcache.js +48 -0
  28. package/lib/objectstorage.js +319 -0
  29. package/lib/remotekeystorage.js +116 -0
  30. package/lib/routes/collection.js +92 -0
  31. package/lib/routes/health.js +24 -0
  32. package/lib/routes/inbox.js +83 -0
  33. package/lib/routes/object.js +69 -0
  34. package/lib/routes/server.js +47 -0
  35. package/lib/routes/user.js +63 -0
  36. package/lib/routes/webfinger.js +36 -0
  37. package/lib/urlformatter.js +97 -0
  38. package/package.json +51 -0
  39. package/tests/activitydistributor.test.js +606 -0
  40. package/tests/activityhandler.test.js +2185 -0
  41. package/tests/activitypubclient.test.js +225 -0
  42. package/tests/actorstorage.test.js +261 -0
  43. package/tests/app.test.js +17 -0
  44. package/tests/authorizer.test.js +306 -0
  45. package/tests/bot.donothing.test.js +30 -0
  46. package/tests/bot.ok.test.js +101 -0
  47. package/tests/botcontext.test.js +674 -0
  48. package/tests/botdatastorage.test.js +87 -0
  49. package/tests/digester.test.js +56 -0
  50. package/tests/fixtures/bots.js +15 -0
  51. package/tests/httpsignature.test.js +200 -0
  52. package/tests/httpsignatureauthenticator.test.js +463 -0
  53. package/tests/keystorage.test.js +89 -0
  54. package/tests/microsyntax.test.js +122 -0
  55. package/tests/objectcache.test.js +133 -0
  56. package/tests/objectstorage.test.js +148 -0
  57. package/tests/remotekeystorage.test.js +76 -0
  58. package/tests/routes.actor.test.js +207 -0
  59. package/tests/routes.collection.test.js +434 -0
  60. package/tests/routes.health.test.js +41 -0
  61. package/tests/routes.inbox.test.js +135 -0
  62. package/tests/routes.object.test.js +519 -0
  63. package/tests/routes.server.test.js +69 -0
  64. package/tests/routes.webfinger.test.js +41 -0
  65. package/tests/urlformatter.test.js +164 -0
  66. package/tests/utils/digest.js +7 -0
  67. package/tests/utils/nock.js +276 -0
@@ -0,0 +1,48 @@
1
+ import TTLCache from '@isaacs/ttlcache'
2
+
3
+ export class ObjectCache {
4
+ #objects = null
5
+ #members = null
6
+ constructor ({ longTTL, shortTTL, maxItems }) {
7
+ this.#objects = new TTLCache({ ttl: shortTTL, max: maxItems })
8
+ this.#members = new TTLCache({ ttl: shortTTL, max: maxItems })
9
+ this.longTTL = longTTL
10
+ this.shortTTL = shortTTL
11
+ this.maxItems = maxItems
12
+ }
13
+
14
+ async initialize () {
15
+ }
16
+
17
+ async get (id) {
18
+ return this.#objects.get(id)
19
+ }
20
+
21
+ async save (object) {
22
+ return this.#objects.set(object.id, object, { ttl: this.longTTL })
23
+ }
24
+
25
+ async saveReceived (object) {
26
+ return this.#objects.set(object.id, object, { ttl: this.shortTTL })
27
+ }
28
+
29
+ async clear (object) {
30
+ return this.#objects.delete(object.id)
31
+ }
32
+
33
+ membershipKey (collection, object) {
34
+ return `${collection.id}:${object.id}`
35
+ }
36
+
37
+ async saveMembership (collection, object, isMember = true) {
38
+ return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.longTTL })
39
+ }
40
+
41
+ async saveMembershipReceived (collection, object, isMember = true) {
42
+ return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.shortTTL })
43
+ }
44
+
45
+ async isMember (collection, object) {
46
+ return this.#members.get(this.membershipKey(collection, object))
47
+ }
48
+ }
@@ -0,0 +1,319 @@
1
+ import assert from 'node:assert'
2
+ import as2 from './activitystreams.js'
3
+
4
+ export class NoSuchObjectError extends Error {
5
+ constructor (id) {
6
+ const message = `No such object: ${id}`
7
+ super(message)
8
+ this.name = 'NoSuchObjectError'
9
+ }
10
+ }
11
+
12
+ export class ObjectStorage {
13
+ #connection = null
14
+ static #MAX_ITEMS_PER_PAGE = 20
15
+
16
+ constructor (connection) {
17
+ this.#connection = connection
18
+ }
19
+
20
+ async initialize () {
21
+ await this.#connection.query(`
22
+ CREATE TABLE IF NOT EXISTS objects (
23
+ id VARCHAR(512) PRIMARY KEY,
24
+ data TEXT NOT NULL,
25
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
27
+ )
28
+ `)
29
+ await this.#connection.query(`
30
+ CREATE TABLE IF NOT EXISTS collections (
31
+ id VARCHAR(512) NOT NULL,
32
+ property VARCHAR(512) NOT NULL,
33
+ first INTEGER NOT NULL,
34
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ PRIMARY KEY (id, property)
37
+ )
38
+ `)
39
+ await this.#connection.query(`
40
+ CREATE TABLE IF NOT EXISTS pages (
41
+ id VARCHAR(512) NOT NULL,
42
+ property VARCHAR(64) NOT NULL,
43
+ item VARCHAR(512) NOT NULL,
44
+ page INTEGER NOT NULL,
45
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+ PRIMARY KEY (id, property, item)
47
+ )
48
+ `)
49
+
50
+ await this.#connection.query(
51
+ `CREATE INDEX IF NOT EXISTS pages_username_property_page
52
+ ON pages (id, property, page);`
53
+ )
54
+ }
55
+
56
+ async create (object) {
57
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
58
+ assert.ok(object, 'object is required')
59
+ assert.ok(object.id, 'object.id is required')
60
+ const id = object.id
61
+ const data = await object.write()
62
+ assert.ok(data, 'object is required')
63
+ assert.ok(typeof data === 'string', 'data must be a string')
64
+ await this.#connection.query(
65
+ 'INSERT INTO objects (id, data) VALUES (?, ?)',
66
+ { replacements: [id, data] }
67
+ )
68
+ }
69
+
70
+ async read (id) {
71
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
72
+ assert.ok(id, 'id is required')
73
+ const data = await this.#connection.query(
74
+ 'SELECT data FROM objects WHERE id = ?',
75
+ { replacements: [id] }
76
+ )
77
+ if (data[0].length === 0) {
78
+ throw new NoSuchObjectError(id)
79
+ }
80
+ return await as2.import(JSON.parse(data[0][0].data))
81
+ }
82
+
83
+ async update (object) {
84
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
85
+ assert.ok(object, 'object is required')
86
+ assert.ok(object.id, 'object.id is required')
87
+ const id = object.id
88
+ const data = await object.write()
89
+ assert.ok(data, 'object is required')
90
+ assert.ok(typeof data === 'string', 'data must be a string')
91
+ await this.#connection.query(
92
+ 'UPDATE objects SET data = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?',
93
+ { replacements: [data, id] }
94
+ )
95
+ }
96
+
97
+ async delete (object) {
98
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
99
+ assert.ok(object, 'object is required')
100
+ assert.ok(object.id, 'object.id is required')
101
+ const id = object.id
102
+ await this.#connection.query('DELETE FROM objects WHERE id = ?', {
103
+ replacements: [id]
104
+ })
105
+ }
106
+
107
+ async #collectionInfo (id, property) {
108
+ assert.ok(id, 'id is required')
109
+ assert.equal(typeof id, 'string', 'id must be a string')
110
+ assert.ok(property, 'property is required')
111
+ assert.equal(typeof property, 'string', 'property must be a string')
112
+ let totalItems = 0
113
+ let first = 1
114
+ let createdAt = null
115
+ const row = await this.#connection.query(
116
+ 'SELECT first, createdAt FROM collections WHERE id = ? AND property = ?',
117
+ { replacements: [id, property] }
118
+ )
119
+ if (row[0].length > 0) {
120
+ first = row[0][0].first
121
+ createdAt = row[0][0].createdAt
122
+ }
123
+ const count = await this.#connection.query(
124
+ 'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ?',
125
+ { replacements: [id, property] }
126
+ )
127
+ if (count[0].length > 0) {
128
+ totalItems = count[0][0]['COUNT(*)']
129
+ }
130
+ assert.equal(typeof totalItems, 'number', 'totalItems must be a number')
131
+ assert.ok(totalItems >= 0, 'totalItems must be greater than or equal to 0')
132
+ assert.equal(typeof first, 'number', 'first must be a number')
133
+ assert.ok(first >= 1, 'first must be greater than or equal to 1')
134
+ return [totalItems, first, createdAt]
135
+ }
136
+
137
+ async getCollection (id, property) {
138
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
139
+ assert.ok(id, 'id is required')
140
+ assert.ok(property, 'property is required')
141
+ const [totalItems, first, createdAt] = await this.#collectionInfo(
142
+ id,
143
+ property
144
+ )
145
+ const context = [
146
+ 'https://www.w3.org/ns/activitystreams'
147
+ ]
148
+ if (property === 'thread') {
149
+ context.push('https://purl.archive.org/socialweb/thread/1.0')
150
+ } else {
151
+ context.push('https://w3id.org/fep/5711')
152
+ }
153
+ const inverse = (property === 'thread') ? 'root' : `${property}Of`
154
+ const collection = await as2.import({
155
+ '@context': context,
156
+ id: `${id}/${property}`,
157
+ type: 'OrderedCollection',
158
+ totalItems,
159
+ first: `${id}/${property}/${first}`,
160
+ last: `${id}/${property}/1`,
161
+ published: createdAt,
162
+ [inverse]: id
163
+ })
164
+ assert.ok(collection, 'collection is required')
165
+ assert.equal(typeof collection, 'object', 'collection must be an object')
166
+ return collection
167
+ }
168
+
169
+ async getCollectionPage (id, property, page) {
170
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
171
+ assert.ok(id, 'id is required')
172
+ assert.equal(typeof id, 'string', 'id must be a string')
173
+ assert.ok(property, 'property is required')
174
+ assert.equal(typeof property, 'string', 'property must be a string')
175
+ assert.ok(page, 'pageNo is required')
176
+ assert.equal(typeof page, 'number', 'page must be a number')
177
+ assert.ok(page >= 1, 'page must be greater than or equal to 1')
178
+
179
+ const [, first] = await this.#collectionInfo(
180
+ id,
181
+ property
182
+ )
183
+
184
+ if (page > first) {
185
+ throw new NoSuchObjectError(`${id}/${property}/${page}`)
186
+ }
187
+
188
+ let items = []
189
+
190
+ const rows = await this.#connection.query(
191
+ 'SELECT item FROM pages WHERE id = ? AND property = ? and page = ? ORDER BY createdAt DESC, item DESC',
192
+ { replacements: [id, property, page] }
193
+ )
194
+
195
+ if (rows[0].length > 0) {
196
+ items = rows[0].map((row) => row.item)
197
+ }
198
+
199
+ const pageObject = await as2.import({
200
+ id: `${id}/${property}/${page}`,
201
+ type: 'OrderedCollectionPage',
202
+ partOf: `${id}/${property}`,
203
+ next: page === 1 ? null : `${id}/${property}/${page - 1}`,
204
+ prev: page === first ? null : `${id}/${property}/${page + 1}`,
205
+ items
206
+ })
207
+
208
+ assert.ok(pageObject, 'collection is required')
209
+ assert.equal(typeof pageObject, 'object', 'collection must be an object')
210
+
211
+ return pageObject
212
+ }
213
+
214
+ async addToCollection (id, property, object) {
215
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
216
+ assert.ok(id, 'id is required')
217
+ assert.equal(typeof id, 'string', 'id must be a string')
218
+ assert.ok(property, 'property is required')
219
+ assert.equal(typeof property, 'string', 'property must be a string')
220
+ assert.ok(object, 'object is required')
221
+ assert.equal(typeof object, 'object', 'object must be an object')
222
+
223
+ const [, first, createdAt] = await this.#collectionInfo(
224
+ id,
225
+ property
226
+ )
227
+
228
+ if (createdAt === null) {
229
+ await this.#connection.query(
230
+ 'INSERT INTO collections (id, property, first) VALUES (?, ?, 1)',
231
+ { replacements: [id, property] }
232
+ )
233
+ }
234
+
235
+ const count = await this.#itemCount(id, property, first)
236
+
237
+ const page = count >= ObjectStorage.#MAX_ITEMS_PER_PAGE ? first + 1 : first
238
+
239
+ if (page > first) {
240
+ await this.#connection.query(
241
+ 'UPDATE collections SET first = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND property = ?',
242
+ { replacements: [page, id, property] }
243
+ )
244
+ }
245
+
246
+ await this.#connection.query(
247
+ 'INSERT INTO pages (id, property, item, page) VALUES (?, ?, ?, ?)',
248
+ { replacements: [id, property, object.id, page] }
249
+ )
250
+ }
251
+
252
+ async #itemCount (id, property, page) {
253
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
254
+ assert.ok(id, 'id is required')
255
+ assert.equal(typeof id, 'string', 'id must be a string')
256
+ assert.ok(property, 'property is required')
257
+ assert.equal(typeof property, 'string', 'property must be a string')
258
+ assert.ok(page, 'page is required')
259
+ assert.equal(typeof page, 'number', 'page must be a number')
260
+ assert.ok(page >= 1, 'page must be greater than or equal to 1')
261
+ const rows = await this.#connection.query(
262
+ 'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ? AND page = ?',
263
+ { replacements: [id, property, page] }
264
+ )
265
+ return rows[0][0]['COUNT(*)']
266
+ }
267
+
268
+ async removeFromCollection (id, property, object) {
269
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
270
+ assert.ok(id, 'id is required')
271
+ assert.equal(typeof id, 'string', 'id must be a string')
272
+ assert.ok(property, 'property is required')
273
+ assert.equal(typeof property, 'string', 'property must be a string')
274
+ assert.ok(object, 'object is required')
275
+ assert.equal(typeof object, 'object', 'object must be an object')
276
+
277
+ await this.#connection.query(
278
+ 'DELETE FROM pages WHERE id = ? AND property = ? AND item = ?',
279
+ { replacements: [id, property, object.id] }
280
+ )
281
+ }
282
+
283
+ async isInCollection (id, property, object) {
284
+ assert.ok(this.#connection, 'ObjectStorage not initialized')
285
+ assert.ok(id, 'id is required')
286
+ assert.equal(typeof id, 'string', 'id must be a string')
287
+ assert.ok(property, 'property is required')
288
+ assert.equal(typeof property, 'string', 'property must be a string')
289
+ assert.ok(object, 'object is required')
290
+ assert.equal(typeof object, 'object', 'object must be an object')
291
+ assert.ok(object.id, 'object.id is required')
292
+ assert.equal(typeof object.id, 'string', 'object.id must be a string')
293
+ const [result] = await this.#connection.query(
294
+ `SELECT COUNT(*) as item_count
295
+ FROM pages
296
+ WHERE id = ? AND property = ? AND item = ?`,
297
+ { replacements: [id, property, object.id] }
298
+ )
299
+ return result[0].item_count > 0
300
+ }
301
+
302
+ async * items (id, property) {
303
+ assert.ok(id, 'username is required')
304
+ assert.equal(typeof id, 'string', 'username must be a string')
305
+ assert.ok(property, 'property is required')
306
+ assert.equal(typeof property, 'string', 'property must be a string')
307
+
308
+ const result = await this.#connection.query(
309
+ `SELECT item
310
+ FROM pages
311
+ WHERE id = ? AND property = ?
312
+ ORDER BY page DESC, createdAt DESC, item DESC;`,
313
+ { replacements: [id, property] }
314
+ )
315
+ for (const row of result[0]) {
316
+ yield as2.import({ id: row.item })
317
+ }
318
+ }
319
+ }
@@ -0,0 +1,116 @@
1
+ const SEC_NS = 'https://w3id.org/security#'
2
+
3
+ export class RemoteKeyStorage {
4
+ #client = null
5
+ #connection = null
6
+ #logger = null
7
+ constructor (client, connection, logger) {
8
+ this.#client = client
9
+ this.#connection = connection
10
+ this.#logger = logger.child({ class: this.constructor.name })
11
+ }
12
+
13
+ async initialize () {
14
+ await this.#connection.query(
15
+ `CREATE TABLE IF NOT EXISTS new_remotekeys (
16
+ id VARCHAR(512) PRIMARY KEY,
17
+ owner VARCHAR(512) NOT NULL,
18
+ publicKeyPem TEXT NOT NULL,
19
+ createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
20
+ updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
21
+ )`
22
+ )
23
+ try {
24
+ await this.#connection.query(
25
+ `INSERT OR IGNORE INTO new_remotekeys (id, owner, publicKeyPem)
26
+ SELECT id, owner, publicKeyPem
27
+ FROM remotekeys`
28
+ )
29
+ } catch (error) {
30
+ this.#logger.debug(
31
+ { error, method: 'RemoteKeyStorage.initialize' },
32
+ 'failed to copy remotekeys to new_remotekeys table'
33
+ )
34
+ }
35
+ }
36
+
37
+ async getPublicKey (id, useCache = true) {
38
+ this.debug(`getPublicKey(${id})`)
39
+ if (useCache) {
40
+ this.debug('using cache')
41
+ const cached = await this.#getCachedPublicKey(id)
42
+ if (cached) {
43
+ this.debug(`getPublicKey(${id}) - cached`)
44
+ return cached
45
+ }
46
+ } else {
47
+ this.debug('skipping cache')
48
+ }
49
+ const remote = await this.#getRemotePublicKey(id)
50
+ if (!remote) {
51
+ this.debug(`getPublicKey(${id}) - remote not found`)
52
+ return null
53
+ }
54
+ await this.#cachePublicKey(id, remote.owner, remote.publicKeyPem)
55
+ return remote
56
+ }
57
+
58
+ async #getCachedPublicKey (id) {
59
+ this.debug(`#getCachedPublicKey(${id})`)
60
+ const [result] = await this.#connection.query(
61
+ 'SELECT publicKeyPem, owner FROM new_remotekeys WHERE id = ?',
62
+ { replacements: [id] }
63
+ )
64
+ if (result.length > 0) {
65
+ this.debug(`cache hit for ${id}`)
66
+ return {
67
+ publicKeyPem: result[0].publicKeyPem,
68
+ owner: result[0].owner
69
+ }
70
+ } else {
71
+ this.debug(`cache miss for ${id}`)
72
+ return null
73
+ }
74
+ }
75
+
76
+ async #getRemotePublicKey (id) {
77
+ this.debug(`#getRemotePublicKey(${id})`)
78
+ const response = await this.#client.getKey(id)
79
+ if (!response) {
80
+ return null
81
+ }
82
+ this.debug(`getRemotePublicKey(${id}) - response: ${await response.id}`)
83
+ let owner = null
84
+ let publicKeyPem = null
85
+ if (response.get(SEC_NS + 'publicKeyPem')) {
86
+ this.debug(`getRemotePublicKey(${id}) - publicKeyPem`)
87
+ owner = response.get(SEC_NS + 'owner')?.first?.id
88
+ publicKeyPem = response.get(SEC_NS + 'publicKeyPem')?.first
89
+ } else if (response.get(SEC_NS + 'publicKey')) {
90
+ this.debug(`getRemotePublicKey(${id}) - publicKey`)
91
+ const publicKey = response.get(SEC_NS + 'publicKey').first
92
+ if (publicKey) {
93
+ owner = publicKey.get(SEC_NS + 'owner')?.first?.id
94
+ publicKeyPem = publicKey.get(SEC_NS + 'publicKeyPem')?.first
95
+ }
96
+ }
97
+ if (!owner || !publicKeyPem) {
98
+ return null
99
+ }
100
+ return { owner, publicKeyPem }
101
+ }
102
+
103
+ async #cachePublicKey (id, owner, publicKeyPem) {
104
+ await this.#connection.query(
105
+ 'INSERT INTO new_remotekeys (id, owner, publicKeyPem) VALUES (?, ?, ?) ' + ' ON CONFLICT(id) DO UPDATE ' +
106
+ 'SET owner=excluded.owner, publicKeyPem = excluded.publicKeyPem;',
107
+ { replacements: [id, owner, publicKeyPem] }
108
+ )
109
+ }
110
+
111
+ debug (...args) {
112
+ if (this.#logger) {
113
+ this.#logger.debug(...args)
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,92 @@
1
+ import express from 'express'
2
+ import as2 from '../activitystreams.js'
3
+ import createHttpError from 'http-errors'
4
+
5
+ const router = express.Router()
6
+
7
+ async function filterAsync (array, asyncPredicate) {
8
+ // 1. Kick off all predicate calls in parallel:
9
+ const checks = array.map(item => asyncPredicate(item))
10
+
11
+ // 2. Wait for all to settle into [true, false, …]:
12
+ const booleans = await Promise.all(checks)
13
+
14
+ // 3. Pick only those whose boolean was true:
15
+ return array.filter((_, idx) => booleans[idx])
16
+ }
17
+
18
+ router.get('/user/:username/:collection', async (req, res, next) => {
19
+ const { username, collection } = req.params
20
+ const { actorStorage, bots } = req.app.locals
21
+ if (!(username in bots)) {
22
+ return next(createHttpError(404, `User ${username} not found`))
23
+ }
24
+ if (collection === 'inbox') {
25
+ return next(createHttpError(403, `No access to ${collection} collection`))
26
+ }
27
+ if (!['outbox', 'liked', 'followers', 'following'].includes(req.params.collection)) {
28
+ return next(createHttpError(404,
29
+ `No such collection ${collection} for user ${username}`))
30
+ }
31
+ const coll = await actorStorage.getCollection(username, collection)
32
+ res.status(200)
33
+ res.type(as2.mediaType)
34
+ res.end(await coll.prettyWrite({ useOriginalContext: true }))
35
+ })
36
+
37
+ router.get('/user/:username/:collection/:n(\\d+)', async (req, res, next) => {
38
+ const { username, collection, n } = req.params
39
+ const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
40
+
41
+ if (!(username in bots)) {
42
+ return next(createHttpError(404, `User ${username} not found`))
43
+ }
44
+ if (collection === 'inbox') {
45
+ return next(createHttpError(403, `No access to ${collection} collection`))
46
+ }
47
+ if (!['outbox', 'liked', 'followers', 'following'].includes(collection)) {
48
+ return next(createHttpError(404,
49
+ `No such collection ${collection} for user ${username}`))
50
+ }
51
+ if (!await actorStorage.hasPage(username, collection, parseInt(n))) {
52
+ return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`))
53
+ }
54
+
55
+ let exported = null
56
+
57
+ try {
58
+ const id = req.auth?.subject
59
+ const remote = (id) ? await as2.import({ id }) : null
60
+ const page = await actorStorage.getCollectionPage(
61
+ username,
62
+ collection,
63
+ parseInt(n)
64
+ )
65
+ exported = await page.export({ useOriginalContext: true })
66
+
67
+ if (['outbox', 'liked'].includes(collection)) {
68
+ exported.items = await filterAsync(exported.items, async (id) => {
69
+ const object = (formatter.isLocal(id))
70
+ ? await objectStorage.read(id)
71
+ : await client.get(id)
72
+ if (!object) {
73
+ req.log.warn({ id }, 'could not load object')
74
+ return false
75
+ }
76
+ req.log.debug({ id, object }, 'loaded object')
77
+ return await authorizer.canRead(remote, object)
78
+ })
79
+ }
80
+ } catch (error) {
81
+ req.log.error(
82
+ { err: error, username, collection, n },
83
+ 'error loading collection page'
84
+ )
85
+ return next(createHttpError(500, 'Error loading collection page'))
86
+ }
87
+ res.status(200)
88
+ res.type(as2.mediaType)
89
+ res.end(JSON.stringify(exported))
90
+ })
91
+
92
+ export default router
@@ -0,0 +1,24 @@
1
+ import express from 'express'
2
+ import createHttpError from 'http-errors'
3
+
4
+ const router = express.Router()
5
+
6
+ router.get('/livez', async (req, res) => {
7
+ res.status(200)
8
+ res.type('text/plain')
9
+ res.end('OK')
10
+ })
11
+
12
+ router.get('/readyz', async (req, res, next) => {
13
+ const connection = req.app.locals.connection
14
+ try {
15
+ await connection.query('SELECT 1')
16
+ res.status(200)
17
+ res.type('text/plain')
18
+ res.end('OK')
19
+ } catch (err) {
20
+ return next(createHttpError(503, 'Service Unavailable'))
21
+ }
22
+ })
23
+
24
+ export default router
@@ -0,0 +1,83 @@
1
+ import express from 'express'
2
+ import as2 from '../activitystreams.js'
3
+ import createHttpError from 'http-errors'
4
+
5
+ const router = express.Router()
6
+
7
+ function isActivity (object) {
8
+ return true
9
+ }
10
+
11
+ function getActor (activity) {
12
+ return activity.actor?.first
13
+ }
14
+
15
+ router.post('/user/:username/inbox', async (req, res, next) => {
16
+ const { username } = req.params
17
+ const { bots, actorStorage, activityHandler } = req.app.locals
18
+ const { subject } = req.auth
19
+ const { logger } = req.app.locals
20
+
21
+ const bot = bots[username]
22
+ if (!bot) {
23
+ return next(createHttpError(404, 'Not Found'))
24
+ }
25
+
26
+ if (!subject) {
27
+ return next(createHttpError(401, 'Unauthorized'))
28
+ }
29
+
30
+ if (!req.body) {
31
+ return next(createHttpError(400, 'No request body provided'))
32
+ }
33
+
34
+ let activity
35
+
36
+ try {
37
+ activity = await as2.import(req.body)
38
+ } catch (err) {
39
+ logger.warn('Failed to import activity', err)
40
+ logger.debug('Request body', req.body)
41
+ return next(createHttpError(400, 'Invalid request body'))
42
+ }
43
+
44
+ if (!isActivity(activity)) {
45
+ return next(createHttpError(400, 'Request body is not an activity'))
46
+ }
47
+
48
+ const actor = getActor(activity)
49
+
50
+ if (!actor) {
51
+ return next(createHttpError(400, 'No actor found in activity'))
52
+ }
53
+
54
+ if (!actor.id) {
55
+ return next(createHttpError(400, 'No actor id found in activity'))
56
+ }
57
+
58
+ if (actor.id !== subject) {
59
+ return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
60
+ }
61
+
62
+ if (await actorStorage.isInCollection(username, 'blocked', actor)) {
63
+ return next(createHttpError(403, 'Forbidden'))
64
+ }
65
+
66
+ if (await actorStorage.isInCollection(bot.username, 'inbox', activity)) {
67
+ return next(createHttpError(400, 'Activity already delivered'))
68
+ }
69
+
70
+ try {
71
+ await activityHandler.handleActivity(bot, activity)
72
+ } catch (err) {
73
+ return next(err)
74
+ }
75
+
76
+ await actorStorage.addToCollection(bot.username, 'inbox', activity)
77
+
78
+ res.status(200)
79
+ res.type('text/plain')
80
+ res.send('OK')
81
+ })
82
+
83
+ export default router