@evanp/activitypub-bot 0.32.3 → 0.34.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.
@@ -1,8 +1,10 @@
1
- import BotMaker from './botmaker.js'
2
1
  import assert from 'node:assert'
3
- import as2 from './activitystreams.js'
2
+
4
3
  import * as ttlcachePkg from '@isaacs/ttlcache'
5
4
 
5
+ import as2 from './activitystreams.js'
6
+ import BotMaker from './botmaker.js'
7
+
6
8
  const TTLCache =
7
9
  ttlcachePkg.TTLCache ?? ttlcachePkg.default ?? ttlcachePkg
8
10
 
@@ -158,6 +160,20 @@ export class ActivityDeliverer {
158
160
  }
159
161
  }
160
162
  }
163
+
164
+ switch (activity.type) {
165
+ case `${NS}Follow`:
166
+ await this.#deliverFollowToAll(activity, bots, deliveredTo)
167
+ break
168
+ }
169
+ }
170
+
171
+ async #deliverFollowToAll (activity, bots, deliveredTo) {
172
+ const object = activity.object?.first
173
+ if (object?.id && this.#isLocal(object) && !deliveredTo.has(this.#formatter.unformat(object.id).username)) {
174
+ this.#logger.debug({ object: object.id, activity: activity.id }, 'Follow not yet delivered to object, delivering now')
175
+ await this.#deliverLocalActor(activity, object, bots, deliveredTo)
176
+ }
161
177
  }
162
178
 
163
179
  async #isRemoteFollowersCollection (actor, object) {
@@ -1,7 +1,9 @@
1
1
  import assert from 'node:assert'
2
- import as2 from './activitystreams.js'
2
+
3
3
  import { LRUCache } from 'lru-cache'
4
4
 
5
+ import as2 from './activitystreams.js'
6
+
5
7
  const NS = 'https://www.w3.org/ns/activitystreams#'
6
8
 
7
9
  const COLLECTION_TYPES = [
@@ -1,7 +1,9 @@
1
- import as2 from './activitystreams.js'
2
- import { nanoid } from 'nanoid'
3
1
  import assert from 'node:assert'
4
2
 
3
+ import { nanoid } from 'nanoid'
4
+
5
+ import as2 from './activitystreams.js'
6
+
5
7
  const AS2 = 'https://www.w3.org/ns/activitystreams#'
6
8
 
7
9
  const THREAD_PROP = 'https://purl.archive.org/socialweb/thread#thread'
@@ -632,16 +634,15 @@ export class ActivityHandler {
632
634
  'following',
633
635
  actor
634
636
  )
635
- await this.#actorStorage.removeFromCollection(
636
- bot.username,
637
- 'pendingFollowing',
638
- actor
639
- )
640
- await this.#actorStorage.removeFromCollection(
641
- bot.username,
642
- 'pendingFollowers',
643
- actor
644
- )
637
+ const followId = await this.#actorStorage.getLastActivity(bot.username, 'Follow', actor)
638
+ if (followId) {
639
+ const followActivity = await this.#objectStorage.read(followId)
640
+ await this.#actorStorage.removeFromCollection(
641
+ bot.username,
642
+ 'pendingFollowing',
643
+ followActivity
644
+ )
645
+ }
645
646
  }
646
647
  }
647
648
 
@@ -827,11 +828,9 @@ export class ActivityHandler {
827
828
  })
828
829
  return
829
830
  }
830
- if (
831
- !(await this.#actorStorage.isInCollection(bot.username, 'followers', actor)) &&
832
- !(await this.#actorStorage.isInCollection(bot.username, 'pendingFollowers', actor))) {
831
+ if (!(await this.#actorStorage.isInCollection(bot.username, 'followers', actor))) {
833
832
  this.#logger.warn({
834
- msg: 'Undo follow activity from actor not in followers or pendingFollowers',
833
+ msg: 'Undo follow activity from actor not in followers',
835
834
  activity: undoActivity.id,
836
835
  followActivity: followActivity.id,
837
836
  actor: actor.id
@@ -839,7 +838,6 @@ export class ActivityHandler {
839
838
  return
840
839
  }
841
840
  await this.#actorStorage.removeFromCollection(bot.username, 'followers', actor)
842
- await this.#actorStorage.removeFromCollection(bot.username, 'pendingFollowers', actor)
843
841
  this.#logger.debug(
844
842
  { bot: this.#botId(bot), activity: undoActivity.id },
845
843
  'Notifying bot of undo follow activity'
@@ -1,11 +1,13 @@
1
- import as2 from './activitystreams.js'
2
- import fetch from 'node-fetch'
3
1
  import assert from 'node:assert'
4
- import createHttpError from 'http-errors'
5
2
  import fs from 'node:fs'
6
3
  import path from 'node:path'
7
4
  import { fileURLToPath } from 'node:url'
8
5
 
6
+ import fetch from 'node-fetch'
7
+ import createHttpError from 'http-errors'
8
+
9
+ import as2 from './activitystreams.js'
10
+
9
11
  const __filename = fileURLToPath(import.meta.url)
10
12
  const __dirname = path.dirname(__filename)
11
13
 
package/lib/botcontext.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import assert from 'node:assert'
2
- import as2 from './activitystreams.js'
2
+
3
3
  import { nanoid } from 'nanoid'
4
4
  import fetch from 'node-fetch'
5
5
 
6
+ import as2 from './activitystreams.js'
7
+
6
8
  const AS2_TYPES = [
7
9
  'application/activity+json',
8
10
  'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
@@ -225,8 +227,7 @@ export class BotContext {
225
227
  assert.ok(actor)
226
228
  assert.equal(typeof actor, 'object')
227
229
  let activity
228
- if (await this.#actorStorage.isInCollection(this.#botId, 'following', actor) ||
229
- await this.#actorStorage.isInCollection(this.#botId, 'pendingFollowing', actor)) {
230
+ if (await this.#actorStorage.isInCollection(this.#botId, 'following', actor)) {
230
231
  const originalId = await this.#actorStorage.getLastActivity(
231
232
  this.#botId,
232
233
  'Follow',
@@ -237,17 +238,24 @@ export class BotContext {
237
238
  }
238
239
  activity = await this.#objectStorage.read(originalId)
239
240
  } else {
240
- await this.#actorStorage.addToCollection(
241
- this.#botId,
242
- 'pendingFollowing',
243
- actor
244
- )
241
+ const pendingId = await this.#actorStorage.getLastActivity(this.#botId, 'Follow', actor)
242
+ if (pendingId) {
243
+ const pendingActivity = await this.#objectStorage.read(pendingId)
244
+ if (await this.#actorStorage.isInCollection(this.#botId, 'pendingFollowing', pendingActivity)) {
245
+ return pendingActivity
246
+ }
247
+ }
245
248
  activity = await this.#doActivity({
246
249
  type: 'Follow',
247
250
  object: actor.id,
248
251
  to: actor.id
249
252
  })
250
253
  await this.#actorStorage.setLastActivity(this.#botId, activity)
254
+ await this.#actorStorage.addToCollection(
255
+ this.#botId,
256
+ 'pendingFollowing',
257
+ activity
258
+ )
251
259
  }
252
260
 
253
261
  return activity
@@ -256,11 +264,15 @@ export class BotContext {
256
264
  async unfollowActor (actor) {
257
265
  assert.ok(actor)
258
266
  assert.equal(typeof actor, 'object')
259
- await this.#actorStorage.removeFromCollection(
260
- this.#botId,
261
- 'pendingFollowing',
262
- actor
263
- )
267
+ const followId = await this.#actorStorage.getLastActivity(this.#botId, 'Follow', actor)
268
+ if (followId) {
269
+ const followActivity = await this.#objectStorage.read(followId)
270
+ await this.#actorStorage.removeFromCollection(
271
+ this.#botId,
272
+ 'pendingFollowing',
273
+ followActivity
274
+ )
275
+ }
264
276
  await this.#actorStorage.removeFromCollection(
265
277
  this.#botId,
266
278
  'following',
@@ -273,11 +285,14 @@ export class BotContext {
273
285
  assert.ok(actor)
274
286
  assert.equal(typeof actor, 'object')
275
287
  await this.#actorStorage.addToCollection(this.#botId, 'blocked', actor)
288
+ const followId = await this.#actorStorage.getLastActivity(this.#botId, 'Follow', actor)
289
+ if (followId) {
290
+ const followActivity = await this.#objectStorage.read(followId)
291
+ await this.#actorStorage.removeFromCollection(this.#botId, 'pendingFollowing', followActivity)
292
+ }
276
293
  for (const coll of [
277
294
  'following',
278
- 'followers',
279
- 'pendingFollowing',
280
- 'pendingFollowers'
295
+ 'followers'
281
296
  ]) {
282
297
  await this.#actorStorage.removeFromCollection(this.#botId, coll, actor)
283
298
  }
@@ -1,7 +1,8 @@
1
- import createHttpError from 'http-errors'
2
1
  import crypto from 'node:crypto'
3
2
  import assert from 'node:assert'
4
3
 
4
+ import createHttpError from 'http-errors'
5
+
5
6
  export class HTTPSignature {
6
7
  static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
7
8
  #logger = null
@@ -1,4 +1,5 @@
1
1
  import createHttpError from 'http-errors'
2
+
2
3
  import BotMaker from './botmaker.js'
3
4
 
4
5
  export class HTTPSignatureAuthenticator {
package/lib/keystorage.js CHANGED
@@ -1,8 +1,9 @@
1
- import { promisify } from 'util'
1
+ import { promisify } from 'node:util'
2
2
  import crypto from 'node:crypto'
3
- import HumanHasher from 'humanhash'
4
3
  import assert from 'node:assert'
5
4
 
5
+ import HumanHasher from 'humanhash'
6
+
6
7
  const generateKeyPair = promisify(crypto.generateKeyPair)
7
8
 
8
9
  export class KeyStorage {
@@ -1,6 +1,6 @@
1
- import { fileURLToPath } from 'url'
2
- import { dirname, resolve } from 'path'
3
- import { readdirSync } from 'fs'
1
+ import { fileURLToPath } from 'node:url'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { readdirSync } from 'node:fs'
4
4
 
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url))
6
6
 
@@ -55,7 +55,38 @@ export class RateLimiter {
55
55
  updated_at = CURRENT_TIMESTAMP`,
56
56
  { replacements: [host, remaining, reset] }
57
57
  )
58
+ } else if (headers.get('server') === 'Mastodon') {
59
+ const limits = await this.peek(host)
60
+ if (!limits || limits.length === 0 || limits[0].reset < (new Date())) {
61
+ const remaining = 299 // 300 - 1 for the current request
62
+ const reset = new Date(Math.ceil(Date.now() / 300000) * 300000)
63
+ this.#logger.debug({ reset, remaining, host }, 'updating')
64
+ await this.#connection.query(
65
+ `INSERT INTO rate_limit (host, remaining, reset)
66
+ VALUES (?, ?, ?)
67
+ ON CONFLICT (host) DO UPDATE
68
+ SET remaining = EXCLUDED.remaining,
69
+ reset = EXCLUDED.reset,
70
+ updated_at = CURRENT_TIMESTAMP`,
71
+ { replacements: [host, remaining, reset] }
72
+ )
73
+ }
74
+ }
75
+ }
76
+
77
+ async peek (host) {
78
+ const [result] = await this.#connection.query(
79
+ 'SELECT remaining, reset FROM rate_limit WHERE host = ?',
80
+ { replacements: [host] }
81
+ )
82
+
83
+ if (result.length === 0) {
84
+ return []
58
85
  }
86
+
87
+ const { remaining, reset } = result[0]
88
+
89
+ return [{ policy: 'default', remaining, reset: new Date(reset) }]
59
90
  }
60
91
 
61
92
  async #getWaitTime (host, maxWaitTime) {
@@ -1,4 +1,7 @@
1
+ import assert from 'node:assert'
2
+
1
3
  const SEC_NS = 'https://w3id.org/security#'
4
+ const DEFAULT_NS = '_:'
2
5
 
3
6
  export class RemoteKeyStorage {
4
7
  #client = null
@@ -27,6 +30,10 @@ export class RemoteKeyStorage {
27
30
  this.debug(`getPublicKey(${id}) - remote not found`)
28
31
  return null
29
32
  }
33
+ if (!await this.#confirmPublicKey(remote.owner, id)) {
34
+ this.#logger.warn({ owner: remote.owner, id }, 'Mismatched owner and key')
35
+ return null
36
+ }
30
37
  await this.#cachePublicKey(id, remote.owner, remote.publicKeyPem)
31
38
  return remote
32
39
  }
@@ -55,21 +62,11 @@ export class RemoteKeyStorage {
55
62
  if (!response) {
56
63
  return null
57
64
  }
58
- this.debug(`getRemotePublicKey(${id}) - response: ${await response.id}`)
59
- let owner = null
60
- let publicKeyPem = null
61
- if (response.get(SEC_NS + 'publicKeyPem')) {
62
- this.debug(`getRemotePublicKey(${id}) - publicKeyPem`)
63
- owner = response.get(SEC_NS + 'owner')?.first?.id
64
- publicKeyPem = response.get(SEC_NS + 'publicKeyPem')?.first
65
- } else if (response.get(SEC_NS + 'publicKey')) {
66
- this.debug(`getRemotePublicKey(${id}) - publicKey`)
67
- const publicKey = response.get(SEC_NS + 'publicKey').first
68
- if (publicKey) {
69
- owner = publicKey.get(SEC_NS + 'owner')?.first?.id
70
- publicKeyPem = publicKey.get(SEC_NS + 'publicKeyPem')?.first
71
- }
72
- }
65
+ this.debug(`getRemotePublicKey(${id}) - response: ${response.id}`)
66
+
67
+ const owner = this.#getOwner(response)
68
+ const publicKeyPem = this.#getPublicKeyPem(response)
69
+
73
70
  if (!owner || !publicKeyPem) {
74
71
  return null
75
72
  }
@@ -89,4 +86,72 @@ export class RemoteKeyStorage {
89
86
  this.#logger.debug(...args)
90
87
  }
91
88
  }
89
+
90
+ async #confirmPublicKey (owner, id) {
91
+ assert.equal(typeof owner, 'string')
92
+ assert.equal(typeof id, 'string')
93
+ let actor
94
+
95
+ try {
96
+ actor = await this.#client.get(owner)
97
+ } catch (err) {
98
+ this.#logger.warn({ err, owner, id }, 'Error getting key owner')
99
+ return false
100
+ }
101
+
102
+ const publicKeyId = this.#getPublicKeyId(actor)
103
+
104
+ if (!publicKeyId) {
105
+ return false
106
+ }
107
+
108
+ return publicKeyId === id
109
+ }
110
+
111
+ #getSecIdProp (obj, prop) {
112
+ assert.strictEqual(typeof obj, 'object')
113
+ assert.strictEqual(typeof prop, 'string')
114
+ let value = obj.get(SEC_NS + prop)
115
+ if (value) {
116
+ return value.first?.id
117
+ }
118
+ value = obj.get(DEFAULT_NS + prop)
119
+ if (value) {
120
+ this.#logger.warn(
121
+ { objectId: obj.id, prop },
122
+ 'security property in default namespace'
123
+ )
124
+ const first = value.first
125
+ return (typeof first === 'string') ? first : first?.id
126
+ }
127
+ return null
128
+ }
129
+
130
+ #getOwner (obj) {
131
+ assert.strictEqual(typeof obj, 'object')
132
+ return this.#getSecIdProp(obj, 'owner')
133
+ }
134
+
135
+ #getPublicKeyPem (obj) {
136
+ assert.strictEqual(typeof obj, 'object')
137
+ const prop = 'publicKeyPem'
138
+ let value = obj.get(SEC_NS + prop)
139
+ if (value) {
140
+ return value.first
141
+ }
142
+ value = obj.get(DEFAULT_NS + prop)
143
+ if (value) {
144
+ this.#logger.warn(
145
+ { objectId: obj.id, prop },
146
+ 'security property in default namespace'
147
+ )
148
+ return value.first
149
+ }
150
+ return null
151
+ }
152
+
153
+ #getPublicKeyId (obj) {
154
+ assert.strictEqual(typeof obj, 'object')
155
+ return this.#getSecIdProp(obj, 'publicKey')
156
+ }
92
157
  }
@@ -1,8 +1,10 @@
1
+ import assert from 'node:assert'
2
+
1
3
  import express from 'express'
2
- import as2 from '../activitystreams.js'
3
4
  import createHttpError from 'http-errors'
5
+
6
+ import as2 from '../activitystreams.js'
4
7
  import BotMaker from '../botmaker.js'
5
- import assert from 'node:assert'
6
8
 
7
9
  const collections = ['outbox', 'liked', 'followers', 'following']
8
10
  const router = express.Router()
@@ -1,8 +1,10 @@
1
+ import http from 'node:http'
2
+
1
3
  import express from 'express'
2
- import as2 from '../activitystreams.js'
3
4
  import createHttpError from 'http-errors'
5
+
6
+ import as2 from '../activitystreams.js'
4
7
  import BotMaker from '../botmaker.js'
5
- import http from 'node:http'
6
8
 
7
9
  const router = express.Router()
8
10
 
@@ -1,7 +1,8 @@
1
1
  import express from 'express'
2
- import as2 from '../activitystreams.js'
3
2
  import createHttpError from 'http-errors'
4
3
 
4
+ import as2 from '../activitystreams.js'
5
+
5
6
  const router = express.Router()
6
7
 
7
8
  export default router
@@ -1,7 +1,9 @@
1
+ import http from 'node:http'
2
+
1
3
  import express from 'express'
2
- import as2 from '../activitystreams.js'
3
4
  import createHttpError from 'http-errors'
4
- import http from 'node:http'
5
+
6
+ import as2 from '../activitystreams.js'
5
7
 
6
8
  const router = express.Router()
7
9
 
@@ -33,6 +33,26 @@ async function botWebfinger (username, req, res, next) {
33
33
  })
34
34
  }
35
35
 
36
+ async function profileWebfinger (username, profileUrl, req, res, next) {
37
+ const { formatter, bots } = req.app.locals
38
+ const bot = await BotMaker.makeBot(bots, username)
39
+ if (!bot) {
40
+ return next(createHttpError(404, `No such bot '${username}'`))
41
+ }
42
+ res.status(200)
43
+ res.type('application/jrd+json')
44
+ res.json({
45
+ subject: profileUrl,
46
+ links: [
47
+ {
48
+ rel: 'alternate',
49
+ type: 'application/activity+json',
50
+ href: formatter.format({ username })
51
+ }
52
+ ]
53
+ })
54
+ }
55
+
36
56
  async function httpsWebfinger (resource, req, res, next) {
37
57
  const { formatter } = req.app.locals
38
58
  assert.ok(formatter)
@@ -42,6 +62,8 @@ async function httpsWebfinger (resource, req, res, next) {
42
62
  const parts = formatter.unformat(resource)
43
63
  if (parts.username && !parts.type && !parts.collection) {
44
64
  return await botWebfinger(parts.username, req, res, next)
65
+ } else if (parts.username && parts.type === 'profile') {
66
+ return await profileWebfinger(parts.username, resource, req, res, next)
45
67
  } else {
46
68
  return next(createHttpError(400, `No webfinger lookup for url ${resource}`))
47
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.32.3",
3
+ "version": "0.34.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",