@evanp/activitypub-bot 0.32.2 → 0.33.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
 
@@ -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'
@@ -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"'
@@ -58,37 +58,31 @@ export class DistributionWorker {
58
58
  { error, activity: activity.id, inbox },
59
59
  'Could not deliver activity due to client error'
60
60
  )
61
- if (error.status === 429) {
61
+ if ([408, 425, 429].includes(error.status)) {
62
62
  this.#logger.debug(
63
63
  { error, activity: activity.id, inbox },
64
- 'Retrying on 429 status'
64
+ 'Retrying on recoverable status'
65
65
  )
66
- let delay
67
- if (error.headers && error.headers['retry-after']) {
68
- this.#logger.debug('using retry-after header')
69
- const retryAfter = error.headers['retry-after']
70
- if (/^\d+$/.test(retryAfter)) {
71
- delay = parseInt(retryAfter, 10) * 1000
72
- } else {
73
- delay = new Date(retryAfter) - Date.now()
74
- }
75
- } else {
76
- this.#logger.debug('exponential backoff')
77
- delay = Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
78
- }
66
+ const delay = this.#retryDelay(error.headers, attempts)
79
67
  await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
80
68
  } else {
81
69
  await this.#jobQueue.fail(jobId, this.#workerId)
82
70
  }
83
71
  } else if (error.status >= 500 && error.status < 600) {
84
- if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
72
+ if ([501, 505, 508, 510].includes(error.status)) {
73
+ this.#logger.warn(
74
+ { error, activity: activity.id, inbox, attempts },
75
+ 'Could not deliver activity due to unrecoverable server error'
76
+ )
77
+ await this.#jobQueue.fail(jobId, this.#workerId)
78
+ } else if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
85
79
  this.#logger.warn(
86
80
  { error, activity: activity.id, inbox, attempts },
87
81
  'Could not deliver activity due to server error; no more attempts'
88
82
  )
89
83
  await this.#jobQueue.fail(jobId, this.#workerId)
90
84
  } else {
91
- const delay = Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
85
+ const delay = this.#retryDelay(error.headers, attempts)
92
86
  this.#logger.warn(
93
87
  { error, activity: activity.id, inbox, attempts, delay },
94
88
  'Could not deliver activity due to server error; will retry'
@@ -110,7 +104,7 @@ export class DistributionWorker {
110
104
  }
111
105
  this.#logger.warn({ err, jobId }, 'Error delivering to bot')
112
106
  if (jobId) {
113
- const delay = Math.round((2 ** ((attempts ?? 1) - 1) * 1000) * (0.5 + Math.random()))
107
+ const delay = this.#retryDelay(null, attempts ?? 1)
114
108
  this.#logger.warn(
115
109
  { err, jobId, attempts, delay },
116
110
  'Retrying job after a delay'
@@ -121,6 +115,20 @@ export class DistributionWorker {
121
115
  }
122
116
  }
123
117
 
118
+ #retryDelay (headers, attempts) {
119
+ if (headers?.['retry-after']) {
120
+ this.#logger.debug('using retry-after header')
121
+ const retryAfter = headers['retry-after']
122
+ if (/^\d+$/.test(retryAfter)) {
123
+ return parseInt(retryAfter, 10) * 1000
124
+ } else {
125
+ return new Date(retryAfter) - Date.now()
126
+ }
127
+ }
128
+ this.#logger.debug('exponential backoff')
129
+ return Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
130
+ }
131
+
124
132
  stop () {
125
133
  this.#running = false
126
134
  }
@@ -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
 
@@ -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.2",
3
+ "version": "0.33.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",