@evanp/activitypub-bot 0.12.1 → 0.13.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/lib/app.js CHANGED
@@ -140,12 +140,30 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
140
140
  app.use('/', serverRouter)
141
141
  app.use('/', userRouter)
142
142
  app.use('/', collectionRouter)
143
- app.use('/', objectRouter)
144
143
  app.use('/', inboxRouter)
144
+ app.use('/', objectRouter)
145
145
  app.use('/', healthRouter)
146
146
  app.use('/', webfingerRouter)
147
147
  app.use('/', sharedInboxRouter)
148
148
 
149
+ app.use(async (req, res) => {
150
+ if (req.accepts('json')) {
151
+ const status = 404
152
+ const title = http.STATUS_CODES[status] || 'Not Found'
153
+ res.status(status)
154
+ res.type('application/problem+json')
155
+ res.json({
156
+ type: 'about:blank',
157
+ title,
158
+ status,
159
+ detail: 'Not found',
160
+ instance: req.originalUrl
161
+ })
162
+ } else {
163
+ res.status(404).send('Not found')
164
+ }
165
+ })
166
+
149
167
  app.use((err, req, res, next) => {
150
168
  const { logger } = req.app.locals
151
169
  let status = 500
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { makeApp } from './app.js'
2
2
  export { default as Bot } from './bot.js'
3
3
  export { default as BotFactory } from './botfactory.js'
4
+ export { default as OKBot } from './bots/ok.js'
5
+ export { default as DoNothingBot } from './bots/donothing.js'
@@ -2,7 +2,9 @@ import express from 'express'
2
2
  import as2 from '../activitystreams.js'
3
3
  import createHttpError from 'http-errors'
4
4
  import BotMaker from '../botmaker.js'
5
+ import assert from 'node:assert'
5
6
 
7
+ const collections = ['outbox', 'liked', 'followers', 'following']
6
8
  const router = express.Router()
7
9
 
8
10
  async function filterAsync (array, asyncPredicate) {
@@ -16,81 +18,95 @@ async function filterAsync (array, asyncPredicate) {
16
18
  return array.filter((_, idx) => booleans[idx])
17
19
  }
18
20
 
19
- router.get('/user/:username/:collection', async (req, res, next) => {
20
- const { username, collection } = req.params
21
- const { actorStorage, bots } = req.app.locals
22
- const bot = await BotMaker.makeBot(bots, username)
23
- if (!bot) {
24
- return next(createHttpError(404, `User ${username} not found`))
25
- }
26
- if (collection === 'inbox') {
27
- return next(createHttpError(403, `No access to ${collection} collection`))
28
- }
29
- if (!['outbox', 'liked', 'followers', 'following'].includes(req.params.collection)) {
30
- return next(createHttpError(404,
31
- `No such collection ${collection} for user ${username}`))
21
+ // This got tricky because Express 5 doesn't let us add regexes to our routes,
22
+ // so the page routes conflict with the object routes in ./object.js. This
23
+ // format lets us define fixed routes for the 4 user collections that support
24
+ // GET
25
+
26
+ function collectionHandler (collection) {
27
+ assert.ok(collections.includes(collection))
28
+ return async (req, res, next) => {
29
+ const { username } = req.params
30
+ const { actorStorage, bots } = req.app.locals
31
+ const bot = await BotMaker.makeBot(bots, username)
32
+ if (!bot) {
33
+ return next(createHttpError(404, `User ${username} not found`))
34
+ }
35
+ const coll = await actorStorage.getCollection(username, collection)
36
+ res.status(200)
37
+ res.type(as2.mediaType)
38
+ res.end(await coll.prettyWrite({ useOriginalContext: true }))
32
39
  }
33
- const coll = await actorStorage.getCollection(username, collection)
34
- res.status(200)
35
- res.type(as2.mediaType)
36
- res.end(await coll.prettyWrite({ useOriginalContext: true }))
37
- })
40
+ }
38
41
 
39
- router.get('/user/:username/:collection/:n(\\d+)', async (req, res, next) => {
40
- const { username, collection, n } = req.params
41
- const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
42
- const bot = await BotMaker.makeBot(bots, username)
42
+ function collectionPageHandler (collection) {
43
+ assert.ok(['outbox', 'liked', 'followers', 'following'].includes(collection))
44
+ return async (req, res, next) => {
45
+ const { username, n } = req.params
46
+ let pageNo
47
+ try {
48
+ pageNo = parseInt(n)
49
+ } catch (err) {
50
+ return next(createHttpError(400, `Invalid page ${n}`))
51
+ }
52
+ const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
53
+ const bot = await BotMaker.makeBot(bots, username)
43
54
 
44
- if (!bot) {
45
- return next(createHttpError(404, `User ${username} not found`))
46
- }
55
+ if (!bot) {
56
+ return next(createHttpError(404, `User ${username} not found`))
57
+ }
47
58
 
48
- if (collection === 'inbox') {
49
- return next(createHttpError(403, `No access to ${collection} collection`))
50
- }
51
- if (!['outbox', 'liked', 'followers', 'following'].includes(collection)) {
52
- return next(createHttpError(404,
53
- `No such collection ${collection} for user ${username}`))
54
- }
55
- if (!await actorStorage.hasPage(username, collection, parseInt(n))) {
56
- return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`))
57
- }
59
+ if (collection === 'inbox') {
60
+ return next(createHttpError(403, `No access to ${collection} collection`))
61
+ }
62
+ if (!await actorStorage.hasPage(username, collection, parseInt(n))) {
63
+ return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`))
64
+ }
58
65
 
59
- let exported = null
66
+ let exported = null
60
67
 
61
- try {
62
- const id = req.auth?.subject
63
- const remote = (id) ? await as2.import({ id }) : null
64
- const page = await actorStorage.getCollectionPage(
65
- username,
66
- collection,
67
- parseInt(n)
68
- )
69
- exported = await page.export({ useOriginalContext: true })
68
+ try {
69
+ const id = req.auth?.subject
70
+ const remote = (id) ? await as2.import({ id }) : null
71
+ const page = await actorStorage.getCollectionPage(
72
+ username,
73
+ collection,
74
+ pageNo
75
+ )
76
+ exported = await page.export({ useOriginalContext: true })
70
77
 
71
- if (['outbox', 'liked'].includes(collection)) {
72
- exported.items = await filterAsync(exported.items, async (id) => {
73
- const object = (formatter.isLocal(id))
74
- ? await objectStorage.read(id)
75
- : await client.get(id)
76
- if (!object) {
77
- req.log.warn({ id }, 'could not load object')
78
- return false
79
- }
80
- req.log.debug({ id, object }, 'loaded object')
81
- return await authorizer.canRead(remote, object)
82
- })
78
+ if (['outbox', 'liked'].includes(collection)) {
79
+ exported.items = await filterAsync(exported.items, async (id) => {
80
+ const object = (formatter.isLocal(id))
81
+ ? await objectStorage.read(id)
82
+ : await client.get(id)
83
+ if (!object) {
84
+ req.log.warn({ id }, 'could not load object')
85
+ return false
86
+ }
87
+ req.log.debug({ id, object }, 'loaded object')
88
+ return await authorizer.canRead(remote, object)
89
+ })
90
+ }
91
+ } catch (error) {
92
+ req.log.error(
93
+ { err: error, username, collection, n },
94
+ 'error loading collection page'
95
+ )
96
+ return next(createHttpError(500, 'Error loading collection page'))
83
97
  }
84
- } catch (error) {
85
- req.log.error(
86
- { err: error, username, collection, n },
87
- 'error loading collection page'
88
- )
89
- return next(createHttpError(500, 'Error loading collection page'))
98
+ res.status(200)
99
+ res.type(as2.mediaType)
100
+ res.end(JSON.stringify(exported))
90
101
  }
91
- res.status(200)
92
- res.type(as2.mediaType)
93
- res.end(JSON.stringify(exported))
94
- })
102
+ }
103
+
104
+ for (const collection of collections) {
105
+ router.get(`/user/:username/${collection}`, collectionHandler(collection))
106
+ router.get(
107
+ `/user/:username/${collection}/:n`,
108
+ collectionPageHandler(collection)
109
+ )
110
+ }
95
111
 
96
112
  export default router
@@ -67,4 +67,12 @@ router.post('/user/:username/inbox', async (req, res, next) => {
67
67
  res.send('OK')
68
68
  })
69
69
 
70
+ router.get('/user/:username/inbox', async (req, res, next) => {
71
+ return next(createHttpError(403, `No access to inbox collection`))
72
+ })
73
+
74
+ router.get('/user/:username/inbox/:n', async (req, res, next) => {
75
+ return next(createHttpError(403, `No access to inbox collection`))
76
+ })
77
+
70
78
  export default router
@@ -6,8 +6,11 @@ const router = express.Router()
6
6
 
7
7
  export default router
8
8
 
9
- router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})', async (req, res, next) => {
9
+ router.get('/user/:username/:type/:nanoid', async (req, res, next) => {
10
10
  const { username, type, nanoid } = req.params
11
+ if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
12
+ return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
13
+ }
11
14
  const { objectStorage, formatter, authorizer } = req.app.locals
12
15
  const id = formatter.format({ username, type, nanoid })
13
16
  const object = await objectStorage.read(id)
@@ -23,12 +26,16 @@ router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})', async (req, res
23
26
  res.end(await object.prettyWrite())
24
27
  })
25
28
 
26
- router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection', async (req, res, next) => {
29
+ router.get('/user/:username/:type/:nanoid/:collection', async (req, res, next) => {
27
30
  const { objectStorage, formatter, authorizer } = req.app.locals
28
31
  if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
29
32
  return next(createHttpError(404, 'Not Found'))
30
33
  }
31
- const id = formatter.format({ username: req.params.username, type: req.params.type, nanoid: req.params.nanoid })
34
+ const { username, type, nanoid } = req.params
35
+ if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
36
+ return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
37
+ }
38
+ const id = formatter.format({ username, type, nanoid })
32
39
  const object = await objectStorage.read(id)
33
40
  if (!object) {
34
41
  return next(createHttpError(404, 'Not Found'))
@@ -43,8 +50,17 @@ router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection', asy
43
50
  res.end(await collection.prettyWrite({ useOriginalContext: true }))
44
51
  })
45
52
 
46
- router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection/:n(\\d+)', async (req, res, next) => {
53
+ router.get('/user/:username/:type/:nanoid/:collection/:n', async (req, res, next) => {
47
54
  const { username, type, nanoid, collection, n } = req.params
55
+ let pageNo
56
+ try {
57
+ pageNo = parseInt(n)
58
+ } catch (err) {
59
+ return next(createHttpError(400, `Invalid page ${n}`))
60
+ }
61
+ if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
62
+ return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
63
+ }
48
64
  const { objectStorage, formatter, authorizer } = req.app.locals
49
65
  if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
50
66
  return next(createHttpError(404, 'Not Found'))
@@ -58,7 +74,11 @@ router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection/:n(\\
58
74
  if (!await authorizer.canRead(remote, object)) {
59
75
  return next(createHttpError(403, 'Forbidden'))
60
76
  }
61
- const collectionPage = await objectStorage.getCollectionPage(id, collection, parseInt(n))
77
+ const collectionPage = await objectStorage.getCollectionPage(
78
+ id,
79
+ collection,
80
+ pageNo
81
+ )
62
82
  const exported = await collectionPage.export({ useOriginalContext: true })
63
83
  if (!Array.isArray(exported.items)) {
64
84
  exported.items = [exported.items]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@isaacs/ttlcache": "^2.1.4",
32
32
  "activitystrea.ms": "3.2",
33
- "express": "^4.22.1",
33
+ "express": "^5.2.1",
34
34
  "http-errors": "^2.0.0",
35
35
  "humanhash": "^1.0.4",
36
36
  "lru-cache": "^11.1.0",
@@ -1,10 +1,12 @@
1
1
  import assert from 'node:assert'
2
2
  import { describe, it } from 'node:test'
3
3
  describe('package exports', () => {
4
- it('exposes Bot, BotFactory, and makeApp', async () => {
5
- const { Bot, BotFactory, makeApp } = await import('../lib/index.js')
4
+ it('exposes Bot, BotFactory, default bots, and makeApp', async () => {
5
+ const { Bot, BotFactory, makeApp, DoNothingBot, OKBot } = await import('../lib/index.js')
6
6
  assert.equal(typeof Bot, 'function')
7
7
  assert.equal(typeof BotFactory, 'function')
8
8
  assert.equal(typeof makeApp, 'function')
9
+ assert.equal(typeof OKBot, 'function')
10
+ assert.equal(typeof DoNothingBot, 'function')
9
11
  })
10
12
  })
@@ -273,129 +273,6 @@ describe('actor collection routes', async () => {
273
273
  it('should return an object with a detail', async () => {
274
274
  assert.strictEqual(typeof response.body.detail, 'string')
275
275
  })
276
- it('should return an object with a detail matching the request', async () => {
277
- assert.strictEqual(response.body.detail, 'No such collection dne for user ok')
278
- })
279
- })
280
-
281
- describe('GET page for non-existent collection for existent user', async () => {
282
- let response = null
283
- it('should work without an error', async () => {
284
- response = await request(app).get('/user/ok/dne/1')
285
- })
286
- it('should return 404 Not Found', async () => {
287
- assert.strictEqual(response.status, 404)
288
- })
289
- it('should return Problem Details JSON', async () => {
290
- assert.strictEqual(response.type, 'application/problem+json')
291
- })
292
- it('should return an object', async () => {
293
- assert.strictEqual(typeof response.body, 'object')
294
- })
295
- it('should return an object with a type', async () => {
296
- assert.strictEqual(typeof response.body.type, 'string')
297
- })
298
- it('should return an object with an type matching the request', async () => {
299
- assert.strictEqual(response.body.type, 'about:blank')
300
- })
301
- it('should return an object with a title', async () => {
302
- assert.strictEqual(typeof response.body.title, 'string')
303
- })
304
- it('should return an object with a title matching the request', async () => {
305
- assert.strictEqual(response.body.title, 'Not Found')
306
- })
307
- it('should return an object with a status', async () => {
308
- assert.strictEqual(typeof response.body.status, 'number')
309
- })
310
- it('should return an object with a status matching the request', async () => {
311
- assert.strictEqual(response.body.status, 404)
312
- })
313
- it('should return an object with a detail', async () => {
314
- assert.strictEqual(typeof response.body.detail, 'string')
315
- })
316
- it('should return an object with a detail matching the request', async () => {
317
- assert.strictEqual(response.body.detail, 'No such collection dne for user ok')
318
- })
319
- })
320
-
321
- describe('GET /user/{botid}/inbox', async () => {
322
- let response = null
323
- it('should work without an error', async () => {
324
- response = await request(app).get('/user/ok/inbox')
325
- })
326
- it('should return 403 Forbidden', async () => {
327
- assert.strictEqual(response.status, 403)
328
- })
329
- it('should return Problem Details JSON', async () => {
330
- assert.strictEqual(response.type, 'application/problem+json')
331
- })
332
- it('should return an object', async () => {
333
- assert.strictEqual(typeof response.body, 'object')
334
- })
335
- it('should return an object with a type', async () => {
336
- assert.strictEqual(typeof response.body.type, 'string')
337
- })
338
- it('should return an object with an type matching the request', async () => {
339
- assert.strictEqual(response.body.type, 'about:blank')
340
- })
341
- it('should return an object with a title', async () => {
342
- assert.strictEqual(typeof response.body.title, 'string')
343
- })
344
- it('should return an object with a title matching the request', async () => {
345
- assert.strictEqual(response.body.title, 'Forbidden')
346
- })
347
- it('should return an object with a status', async () => {
348
- assert.strictEqual(typeof response.body.status, 'number')
349
- })
350
- it('should return an object with a status matching the request', async () => {
351
- assert.strictEqual(response.body.status, 403)
352
- })
353
- it('should return an object with a detail', async () => {
354
- assert.strictEqual(typeof response.body.detail, 'string')
355
- })
356
- it('should return an object with a detail matching the request', async () => {
357
- assert.strictEqual(response.body.detail, 'No access to inbox collection')
358
- })
359
- })
360
-
361
- describe('GET /user/{botid}/inbox/1', async () => {
362
- let response = null
363
- it('should work without an error', async () => {
364
- response = await request(app).get('/user/ok/inbox/1')
365
- })
366
- it('should return 403 Forbidden', async () => {
367
- assert.strictEqual(response.status, 403)
368
- })
369
- it('should return Problem Details JSON', async () => {
370
- assert.strictEqual(response.type, 'application/problem+json')
371
- })
372
- it('should return an object', async () => {
373
- assert.strictEqual(typeof response.body, 'object')
374
- })
375
- it('should return an object with a type', async () => {
376
- assert.strictEqual(typeof response.body.type, 'string')
377
- })
378
- it('should return an object with an type matching the request', async () => {
379
- assert.strictEqual(response.body.type, 'about:blank')
380
- })
381
- it('should return an object with a title', async () => {
382
- assert.strictEqual(typeof response.body.title, 'string')
383
- })
384
- it('should return an object with a title matching the request', async () => {
385
- assert.strictEqual(response.body.title, 'Forbidden')
386
- })
387
- it('should return an object with a status', async () => {
388
- assert.strictEqual(typeof response.body.status, 'number')
389
- })
390
- it('should return an object with a status matching the request', async () => {
391
- assert.strictEqual(response.body.status, 403)
392
- })
393
- it('should return an object with a detail', async () => {
394
- assert.strictEqual(typeof response.body.detail, 'string')
395
- })
396
- it('should return an object with a detail matching the request', async () => {
397
- assert.strictEqual(response.body.detail, 'No access to inbox collection')
398
- })
399
276
  })
400
277
 
401
278
  describe('GET /user/{botid}/outbox/1 with contents', async () => {
@@ -20,6 +20,86 @@ describe('routes.inbox', async () => {
20
20
  app = await makeApp(databaseUrl, origin, bots, 'silent')
21
21
  })
22
22
 
23
+ describe('GET /user/{botid}/inbox', async () => {
24
+ let response = null
25
+ it('should work without an error', async () => {
26
+ response = await request(app).get('/user/ok/inbox')
27
+ })
28
+ it('should return 403 Forbidden', async () => {
29
+ assert.strictEqual(response.status, 403)
30
+ })
31
+ it('should return Problem Details JSON', async () => {
32
+ assert.strictEqual(response.type, 'application/problem+json')
33
+ })
34
+ it('should return an object', async () => {
35
+ assert.strictEqual(typeof response.body, 'object')
36
+ })
37
+ it('should return an object with a type', async () => {
38
+ assert.strictEqual(typeof response.body.type, 'string')
39
+ })
40
+ it('should return an object with an type matching the request', async () => {
41
+ assert.strictEqual(response.body.type, 'about:blank')
42
+ })
43
+ it('should return an object with a title', async () => {
44
+ assert.strictEqual(typeof response.body.title, 'string')
45
+ })
46
+ it('should return an object with a title matching the request', async () => {
47
+ assert.strictEqual(response.body.title, 'Forbidden')
48
+ })
49
+ it('should return an object with a status', async () => {
50
+ assert.strictEqual(typeof response.body.status, 'number')
51
+ })
52
+ it('should return an object with a status matching the request', async () => {
53
+ assert.strictEqual(response.body.status, 403)
54
+ })
55
+ it('should return an object with a detail', async () => {
56
+ assert.strictEqual(typeof response.body.detail, 'string')
57
+ })
58
+ it('should return an object with a detail matching the request', async () => {
59
+ assert.strictEqual(response.body.detail, 'No access to inbox collection')
60
+ })
61
+ })
62
+
63
+ describe('GET /user/{botid}/inbox/1', async () => {
64
+ let response = null
65
+ it('should work without an error', async () => {
66
+ response = await request(app).get('/user/ok/inbox/1')
67
+ })
68
+ it('should return 403 Forbidden', async () => {
69
+ assert.strictEqual(response.status, 403)
70
+ })
71
+ it('should return Problem Details JSON', async () => {
72
+ assert.strictEqual(response.type, 'application/problem+json')
73
+ })
74
+ it('should return an object', async () => {
75
+ assert.strictEqual(typeof response.body, 'object')
76
+ })
77
+ it('should return an object with a type', async () => {
78
+ assert.strictEqual(typeof response.body.type, 'string')
79
+ })
80
+ it('should return an object with an type matching the request', async () => {
81
+ assert.strictEqual(response.body.type, 'about:blank')
82
+ })
83
+ it('should return an object with a title', async () => {
84
+ assert.strictEqual(typeof response.body.title, 'string')
85
+ })
86
+ it('should return an object with a title matching the request', async () => {
87
+ assert.strictEqual(response.body.title, 'Forbidden')
88
+ })
89
+ it('should return an object with a status', async () => {
90
+ assert.strictEqual(typeof response.body.status, 'number')
91
+ })
92
+ it('should return an object with a status matching the request', async () => {
93
+ assert.strictEqual(response.body.status, 403)
94
+ })
95
+ it('should return an object with a detail', async () => {
96
+ assert.strictEqual(typeof response.body.detail, 'string')
97
+ })
98
+ it('should return an object with a detail matching the request', async () => {
99
+ assert.strictEqual(response.body.detail, 'No access to inbox collection')
100
+ })
101
+ })
102
+
23
103
  describe('can handle an incoming activity', async () => {
24
104
  const username = 'actor1'
25
105
  const botName = 'test0'