@evanp/activitypub-bot 0.16.7 → 0.18.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/.eslintrc ADDED
@@ -0,0 +1,7 @@
1
+ env:
2
+ node: true
3
+ es2022: true
4
+ parserOptions:
5
+ sourceType: module
6
+ extends:
7
+ - standard
package/README.md CHANGED
@@ -198,6 +198,18 @@ Called when the server receives a public activity to its shared inbox. This can
198
198
 
199
199
  Called when one of the bot's objects is shared by another actor. The first argument is the object, and the second is the `Announce` activity itself. This method is called after the activity has been added to the `shares` collection.
200
200
 
201
+ #### async actorOK (actor, activity)
202
+
203
+ Lets the bot override the default check for matching the actor who sent an activity with the
204
+ actor who did the activity. Usually, these need to be the same, but for some sub-protocols, like
205
+ relays, it can be different. Returns a boolean saying whether the actor is OK.
206
+
207
+ #### async handleActivity (activity)
208
+
209
+ Runs *before* default handling for an activity. This is a chance to do non-standard handling
210
+ for activities. Boolean return; truthy return means that the bot has already handled the
211
+ activity and standard handling should be skipped. The activity will still be delivered to inboxes. Use with caution!
212
+
201
213
  ### BotFactory
202
214
 
203
215
  The BotFactory interface lets you have a lot of bots that act in a similar way without declaring them explicitly in the bots config file.
@@ -297,6 +309,11 @@ Sends a `Block` activity for the passed-in actor in [activitystrea.ms](#activity
297
309
 
298
310
  Sends an `Undo`/`Block` activity for the passed-in actor in [activitystrea.ms](#activitystreams) form.
299
311
 
312
+ #### async doActivity (data, distribute = true)
313
+
314
+ Sends an arbitrary activity object out into the network. This requires some
315
+ care! `id`, `actor`, and timestamps will be added automatically. `distribute` flag is for sensitive activities that shouldn't be distributed to anyone.
316
+
300
317
  #### async toActorId (webfinger)
301
318
 
302
319
  Gets the `id` of the [ActivityPub Actor](https://www.w3.org/TR/activitypub/#actors) with the given [WebFinger](https://en.wikipedia.org/wiki/WebFinger) identity.
@@ -38,10 +38,6 @@ export class ActivityDeliverer {
38
38
  this.#deliverBotQueue = new PQueue({ max: ActivityDeliverer.#MAX_BOT_QUEUE })
39
39
  }
40
40
 
41
- isActivity (object) {
42
- return true
43
- }
44
-
45
41
  getActor (activity) {
46
42
  return activity.actor?.first
47
43
  }
@@ -64,10 +60,10 @@ export class ActivityDeliverer {
64
60
  }
65
61
 
66
62
  async onIdle () {
67
- this.#logger.debug(`Awaiting delivery queues`)
63
+ this.#logger.debug('Awaiting delivery queues')
68
64
  await this.#deliverAllQueue.onIdle()
69
65
  await this.#deliverBotQueue.onIdle()
70
- this.#logger.debug(`Done awaiting delivery queues`)
66
+ this.#logger.debug('Done awaiting delivery queues')
71
67
  }
72
68
 
73
69
  async #deliverTo (activity, bot) {
@@ -95,11 +95,11 @@ export class ActivityDistributor {
95
95
  }
96
96
 
97
97
  async * #public (activity, username) {
98
- yield* this.#recipients(activity, username, ['to', 'cc', 'audience'])
98
+ yield * this.#recipients(activity, username, ['to', 'cc', 'audience'])
99
99
  }
100
100
 
101
101
  async * #private (activity, username) {
102
- yield* this.#recipients(activity, username, ['bto', 'bcc'])
102
+ yield * this.#recipients(activity, username, ['bto', 'bcc'])
103
103
  }
104
104
 
105
105
  async * #recipients (activity, username, props) {
@@ -108,52 +108,52 @@ export class ActivityDistributor {
108
108
  if (p) {
109
109
  for (const value of p) {
110
110
  const id = value.id
111
- this.#logger.debug({id}, 'Checking recipient')
111
+ this.#logger.debug({ id }, 'Checking recipient')
112
112
  if (ActivityDistributor.#PUBLIC.includes(id)) {
113
113
  this.#logger.debug(
114
114
  { activity: activity.id },
115
115
  'Skipping public delivery'
116
116
  )
117
117
  } else if (this.#formatter.isLocal(id)) {
118
- this.#logger.debug({id}, 'Unformatting local recipient')
118
+ this.#logger.debug({ id }, 'Unformatting local recipient')
119
119
  const parts = this.#formatter.unformat(id)
120
120
  this.#logger.debug(parts, 'Local recipient')
121
121
  if (this.#isLocalActor(parts)) {
122
- this.#logger.debug({id}, 'Local actor')
122
+ this.#logger.debug({ id }, 'Local actor')
123
123
  yield id
124
124
  } else if (this.#isLocalCollection(parts)) {
125
- this.#logger.debug({id}, 'Local collection')
125
+ this.#logger.debug({ id }, 'Local collection')
126
126
  for await (const item of this.#actorStorage.items(parts.username, parts.collection)) {
127
- this.#logger.debug({id: item.id}, 'Local collection member')
127
+ this.#logger.debug({ id: item.id }, 'Local collection member')
128
128
  yield item.id
129
129
  }
130
130
  } else {
131
- this.#logger.warn({id}, 'Local non-actor non-collection')
131
+ this.#logger.warn({ id }, 'Local non-actor non-collection')
132
132
  }
133
133
  } else {
134
134
  let obj
135
135
  try {
136
136
  obj = await this.#client.get(id, username)
137
137
  } catch (err) {
138
- this.#logger.warn({id, err}, 'Cannot get recipient, skipping')
138
+ this.#logger.warn({ id, err }, 'Cannot get recipient, skipping')
139
139
  continue
140
140
  }
141
141
  if (this.#isRemoteActor(obj)) {
142
- this.#logger.debug({id}, 'Remote actor')
142
+ this.#logger.debug({ id }, 'Remote actor')
143
143
  yield id
144
144
  } else if (this.#isRemoteCollection(obj)) {
145
- this.#logger.debug({id}, 'Remote collection')
145
+ this.#logger.debug({ id }, 'Remote collection')
146
146
  try {
147
147
  for await (const item of this.#client.items(obj.id, username)) {
148
- this.#logger.debug({id: item.id}, 'Remote collection member')
148
+ this.#logger.debug({ id: item.id }, 'Remote collection member')
149
149
  yield item.id
150
150
  }
151
151
  } catch (err) {
152
- this.#logger.warn({id, err}, 'Cannot iterate, skipping')
152
+ this.#logger.warn({ id, err }, 'Cannot iterate, skipping')
153
153
  continue
154
154
  }
155
155
  } else {
156
- this.#logger.warn({id}, 'Remote non-actor non-collection')
156
+ this.#logger.warn({ id }, 'Remote non-actor non-collection')
157
157
  }
158
158
  }
159
159
  }
@@ -1,5 +1,6 @@
1
1
  import as2 from './activitystreams.js'
2
2
  import { nanoid } from 'nanoid'
3
+ import assert from 'node:assert'
3
4
 
4
5
  const AS2 = 'https://www.w3.org/ns/activitystreams#'
5
6
 
@@ -35,6 +36,27 @@ export class ActivityHandler {
35
36
  }
36
37
 
37
38
  async handleActivity (bot, activity) {
39
+ assert.ok(bot, 'bot parameter is required')
40
+ assert.strictEqual(typeof bot, 'object', 'bot parameter must be an object')
41
+ assert.ok(activity, 'activity parameter is required')
42
+ assert.strictEqual(
43
+ typeof activity,
44
+ 'object',
45
+ 'activity parameter must be an object'
46
+ )
47
+
48
+ let handled = false
49
+
50
+ try {
51
+ handled = await bot.handleActivity(activity)
52
+ } catch (err) {
53
+ this.#logger.warn({ err }, `Bot ${bot.username} errored on activity`)
54
+ }
55
+
56
+ if (handled) {
57
+ return
58
+ }
59
+
38
60
  switch (activity.type) {
39
61
  case AS2 + 'Create': await this.#handleCreate(bot, activity); break
40
62
  case AS2 + 'Update': await this.#handleUpdate(bot, activity); break
@@ -80,7 +102,7 @@ export class ActivityHandler {
80
102
  await this.#handleCreateThread(bot, activity, actor, object)
81
103
  if (this.#isMention(bot, object)) {
82
104
  this.#logger.debug(
83
- { username: bot.username, object: object.id },
105
+ { username: bot.username, object: object.id },
84
106
  'bot mentioned'
85
107
  )
86
108
  await bot.onMention(object, activity)
@@ -875,7 +897,7 @@ export class ActivityHandler {
875
897
  if (!type || typeof type !== 'string') {
876
898
  return 'activity'
877
899
  }
878
- const parts = type.split(/[\/#]/)
900
+ const parts = type.split(/[/#]/)
879
901
  return parts[parts.length - 1].toLowerCase()
880
902
  }
881
903
 
@@ -319,7 +319,8 @@ export class ActorStorage {
319
319
  SET activity_id = EXCLUDED.activity_id,
320
320
  updatedAt = CURRENT_TIMESTAMP
321
321
  `,
322
- { replacements: [
322
+ {
323
+ replacements: [
323
324
  username,
324
325
  this.#clearNS(activity.type),
325
326
  activity.object.first.id,
package/lib/app.js CHANGED
@@ -188,10 +188,10 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
188
188
  })
189
189
 
190
190
  app.onIdle = async () => {
191
- logger.debug(`Awaiting components`)
191
+ logger.debug('Awaiting components')
192
192
  await distributor.onIdle()
193
193
  await deliverer.onIdle()
194
- logger.debug(`Awaiting components`)
194
+ logger.debug('Done awaiting components')
195
195
  }
196
196
 
197
197
  app.cleanup = async () => {
package/lib/authorizer.js CHANGED
@@ -101,7 +101,7 @@ export class Authorizer {
101
101
  } else if (this.#formatter.isLocal(object.id)) {
102
102
  const parts = this.#formatter.unformat(object.id)
103
103
  return as2.import({
104
- id: this.#formatter.format({username: parts.username})
104
+ id: this.#formatter.format({ username: parts.username })
105
105
  })
106
106
  } else {
107
107
  return null
package/lib/bot.js CHANGED
@@ -48,4 +48,12 @@ export default class Bot {
48
48
  async onPublic (activity) {
49
49
  ; // no-op
50
50
  }
51
+
52
+ async actorOK (actorId, activity) {
53
+ return false
54
+ }
55
+
56
+ async handleActivity (activity) {
57
+ return false
58
+ }
51
59
  }
package/lib/botcontext.js CHANGED
@@ -143,7 +143,7 @@ export class BotContext {
143
143
  attributedTo: this.#formatter.format({ username: this.#botId })
144
144
  })
145
145
  await this.#objectStorage.create(note)
146
- const activity = await this.#doActivity({
146
+ await this.#doActivity({
147
147
  '@context': [
148
148
  'https://www.w3.org/ns/activitystreams',
149
149
  'https://purl.archive.org/socialweb/thread/1.0',
@@ -394,6 +394,12 @@ export class BotContext {
394
394
  return await this.#undoActivity('Announce', obj)
395
395
  }
396
396
 
397
+ async doActivity (data, distribute = true) {
398
+ assert.ok(data)
399
+ assert.equal(typeof data, 'object')
400
+ return await this.#doActivity(data, distribute)
401
+ }
402
+
397
403
  async #findInOutbox (type, obj) {
398
404
  const full = `https://www.w3.org/ns/activitystreams#${type}`
399
405
  let found = null
@@ -442,7 +448,7 @@ export class BotContext {
442
448
  type,
443
449
  id: this.#formatter.format({
444
450
  username: this.#botId,
445
- type: type,
451
+ type,
446
452
  nanoid: nanoid()
447
453
  }),
448
454
  actor: {
package/lib/bots/ok.js CHANGED
@@ -23,7 +23,7 @@ export default class OKBot extends Bot {
23
23
  object.attributedTo?.first.id ||
24
24
  activity.actor?.first.id
25
25
  this._context.logger.debug(
26
- { object: object.id, attributedTo: attributedTo },
26
+ { object: object.id, attributedTo },
27
27
  'attributed to'
28
28
  )
29
29
  const wf = await this._context.toWebfinger(attributedTo)
@@ -0,0 +1,38 @@
1
+ import Bot from '../bot.js'
2
+
3
+ export default class RelayClientBot extends Bot {
4
+ #relay
5
+
6
+ constructor (username, relay) {
7
+ super(username)
8
+ this.#relay = relay
9
+ }
10
+
11
+ get fullname () {
12
+ return 'Relay Client Bot'
13
+ }
14
+
15
+ get description () {
16
+ return 'A bot for subscribing to relays'
17
+ }
18
+
19
+ async initialize (context) {
20
+ super.initialize(context)
21
+ if (!(await this._context.hasData(`follow:${this.#relay}`))) {
22
+ await this.#followRelay()
23
+ }
24
+ }
25
+
26
+ async #followRelay () {
27
+ const activity = await this._context.doActivity({
28
+ to: this.#relay,
29
+ type: 'Follow',
30
+ object: 'https://www.w3.org/ns/activitystreams#Public'
31
+ })
32
+ this._context.setData(`follow:${this.#relay}`, activity.id)
33
+ }
34
+
35
+ async actorOK (actorId, activity) {
36
+ return (actorId === this.#relay)
37
+ }
38
+ }
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert'
2
+
3
+ import Bot from '../bot.js'
4
+
5
+ const NS = 'https://www.w3.org/ns/activitystreams#'
6
+ const FOLLOW = `${NS}Follow`
7
+ const PUBLICS = [
8
+ 'Public',
9
+ 'as:Public',
10
+ `${NS}Public`
11
+ ]
12
+ const CLIENTS = 'relay-clients'
13
+
14
+ export default class RelayServerBot extends Bot {
15
+ get fullname () {
16
+ return 'Relay Server Bot'
17
+ }
18
+
19
+ get description () {
20
+ return 'A bot for subscribing to relays'
21
+ }
22
+
23
+ async handleActivity (activity) {
24
+ if (activity.type === FOLLOW &&
25
+ PUBLICS.includes(activity.object?.first?.id)) {
26
+ this._context.logger.debug(
27
+ { activity: activity.id },
28
+ 'Non-default handling for relay follow'
29
+ )
30
+ const actorId = activity.actor?.first?.id
31
+ if (actorId) {
32
+ this._context.logger.info(
33
+ { actor: actorId },
34
+ 'Adding actor as relay follower'
35
+ )
36
+ // FIXME: will this work at scale?
37
+ const clients = (await this._context.hasData(CLIENTS))
38
+ ? await this._context.getData(CLIENTS)
39
+ : []
40
+ assert.ok(Array.isArray(clients))
41
+ const clientSet = new Set(clients)
42
+ if (!clientSet.has(actorId)) {
43
+ clientSet.add(actorId)
44
+ await this._context.setData(CLIENTS, Array.from(clientSet))
45
+ }
46
+ this._context.logger.debug(
47
+ { activity: activity.id },
48
+ 'Accepting follow activity'
49
+ )
50
+ await this._context.doActivity({
51
+ type: 'Accept',
52
+ object: activity.id,
53
+ to: actorId
54
+ })
55
+ }
56
+ return true
57
+ } else {
58
+ return false
59
+ }
60
+ }
61
+ }
@@ -43,8 +43,8 @@ export class HTTPSignature {
43
43
  throw createHttpError(401, 'No algorithm provided')
44
44
  }
45
45
  this.#logger.debug({ algorithm }, 'validating signature')
46
- if (algorithm !== 'rsa-sha256') {
47
- throw createHttpError(401, 'Only rsa-sha256 is supported')
46
+ if (!(algorithm === 'rsa-sha256' || (algorithm === 'hs2019' && this.#isRSAKey(publicKeyPem)))) {
47
+ throw createHttpError(401, 'Only rsa-sha256 or hs2019 with RSA supported')
48
48
  }
49
49
  if (!params.headers) {
50
50
  throw createHttpError(401, 'No headers provided')
@@ -192,4 +192,8 @@ export class HTTPSignature {
192
192
  verifier.end()
193
193
  return isValid
194
194
  }
195
+
196
+ #isRSAKey (publicKeyPem) {
197
+ return crypto.createPublicKey(publicKeyPem).asymmetricKeyType === 'rsa'
198
+ }
195
199
  }
@@ -99,7 +99,7 @@ function collectionPageHandler (collection) {
99
99
  } catch (err) {
100
100
  req.log.debug(
101
101
  { err, remote: remote.id, object: object.id },
102
- `Error checking read access`
102
+ 'Error checking read access'
103
103
  )
104
104
  result = false
105
105
  }
@@ -35,7 +35,7 @@ router.post('/user/:username/inbox', async (req, res, next) => {
35
35
  return next(createHttpError(400, 'Invalid request body'))
36
36
  }
37
37
 
38
- if (!deliverer.isActivity(activity)) {
38
+ if (!activity.isActivity()) {
39
39
  return next(createHttpError(400, 'Request body is not an activity'))
40
40
  }
41
41
 
@@ -49,7 +49,7 @@ router.post('/user/:username/inbox', async (req, res, next) => {
49
49
  return next(createHttpError(400, 'No actor id found in activity'))
50
50
  }
51
51
 
52
- if (actor.id !== subject) {
52
+ if (actor.id !== subject && !(await bot.actorOK(subject, activity))) {
53
53
  return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
54
54
  }
55
55
 
@@ -71,11 +71,11 @@ router.post('/user/:username/inbox', async (req, res, next) => {
71
71
  })
72
72
 
73
73
  router.get('/user/:username/inbox', async (req, res, next) => {
74
- return next(createHttpError(403, `No access to inbox collection`))
74
+ return next(createHttpError(403, 'No access to inbox collection'))
75
75
  })
76
76
 
77
77
  router.get('/user/:username/inbox/:n', async (req, res, next) => {
78
- return next(createHttpError(403, `No access to inbox collection`))
78
+ return next(createHttpError(403, 'No access to inbox collection'))
79
79
  })
80
80
 
81
81
  export default router
@@ -5,6 +5,19 @@ import http from 'node:http'
5
5
 
6
6
  const router = express.Router()
7
7
 
8
+ async function asyncSome (array, asyncPredicate) {
9
+ for (let i = 0; i < array.length; i++) {
10
+ if (await asyncPredicate(array[i], i, array)) {
11
+ return true
12
+ }
13
+ }
14
+ return false
15
+ }
16
+
17
+ async function actorOK (subject, activity, bots) {
18
+ await asyncSome(Object.values(bots), bot => bot.actorOK(subject, activity))
19
+ }
20
+
8
21
  router.post('/shared/inbox', async (req, res, next) => {
9
22
  const { bots, deliverer, logger } = req.app.locals
10
23
  const { subject } = req.auth
@@ -27,7 +40,7 @@ router.post('/shared/inbox', async (req, res, next) => {
27
40
  return next(createHttpError(400, 'Invalid request body'))
28
41
  }
29
42
 
30
- if (!deliverer.isActivity(activity)) {
43
+ if (!activity.isActivity()) {
31
44
  return next(createHttpError(400, 'Request body is not an activity'))
32
45
  }
33
46
 
@@ -41,7 +54,7 @@ router.post('/shared/inbox', async (req, res, next) => {
41
54
  return next(createHttpError(400, 'No actor id found in activity'))
42
55
  }
43
56
 
44
- if (actor.id !== subject) {
57
+ if (actor.id !== subject && !(await actorOK(subject, activity, bots))) {
45
58
  return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
46
59
  }
47
60
 
@@ -20,9 +20,9 @@ export class UrlFormatter {
20
20
  let major = null
21
21
  if (type) {
22
22
  if (nanoid) {
23
- major = `${base}/${type}/${nanoid}`
23
+ major = `${base}/${type.toLowerCase()}/${nanoid}`
24
24
  } else if (type === 'publickey') {
25
- major = `${base}/${type}`
25
+ major = `${base}/${type.toLowerCase()}`
26
26
  } else {
27
27
  throw new Error('Cannot format URL without nanoid')
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.16.7",
3
+ "version": "0.18.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "test": "NODE_ENV=test node --test",
12
- "start": "npx activitypub-bot"
12
+ "start": "npx activitypub-bot",
13
+ "lint": "eslint ."
13
14
  },
14
15
  "repository": {
15
16
  "type": "git",
@@ -29,7 +30,7 @@
29
30
  "homepage": "https://github.com/evanp/activitypub-bot#readme",
30
31
  "dependencies": {
31
32
  "@isaacs/ttlcache": "^2.1.4",
32
- "activitystrea.ms": "3.2",
33
+ "activitystrea.ms": "^3.3.0",
33
34
  "express": "^5.2.1",
34
35
  "http-errors": "^2.0.0",
35
36
  "humanhash": "^1.0.4",
@@ -42,9 +43,13 @@
42
43
  "sequelize": "^6.37.7"
43
44
  },
44
45
  "devDependencies": {
45
- "@evanp/activitypub-nock": "^0.4.4",
46
+ "@evanp/activitypub-nock": "^0.5.0",
47
+ "eslint": "^8.57.1",
48
+ "eslint-config-standard": "^17.1.0",
49
+ "eslint-plugin-import": "^2.29.1",
50
+ "eslint-plugin-n": "^16.6.2",
51
+ "eslint-plugin-promise": "^6.6.0",
46
52
  "nock": "^14.0.5",
47
- "standard": "^17.1.2",
48
53
  "supertest": "^7.1.4"
49
54
  },
50
55
  "optionalDependencies": {