@evanp/activitypub-bot 0.38.4 → 0.39.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.
@@ -4,9 +4,10 @@ import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
6
  import fetch from 'node-fetch'
7
- import createHttpError from 'http-errors'
7
+ import { ProblemDetailsError } from './errors.js'
8
8
 
9
9
  import as2 from './activitystreams.js'
10
+ import { SignaturePolicyStorage } from './signaturepolicystorage.js'
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url)
12
13
  const __dirname = path.dirname(__filename)
@@ -37,8 +38,10 @@ export class ActivityPubClient {
37
38
  #logger = null
38
39
  #limiter
39
40
  #cache
41
+ #messageSigner
42
+ #policyStorage
40
43
 
41
- constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache) {
44
+ constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache, messageSigner, policyStorage) {
42
45
  assert.strictEqual(typeof keyStorage, 'object')
43
46
  assert.strictEqual(typeof urlFormatter, 'object')
44
47
  assert.strictEqual(typeof signer, 'object')
@@ -46,6 +49,8 @@ export class ActivityPubClient {
46
49
  assert.strictEqual(typeof logger, 'object')
47
50
  assert.strictEqual(typeof limiter, 'object')
48
51
  assert.strictEqual(typeof cache, 'object')
52
+ assert.strictEqual(typeof messageSigner, 'object')
53
+ assert.strictEqual(typeof policyStorage, 'object')
49
54
  this.#keyStorage = keyStorage
50
55
  this.#urlFormatter = urlFormatter
51
56
  this.#signer = signer
@@ -53,6 +58,8 @@ export class ActivityPubClient {
53
58
  this.#logger = logger.child({ class: this.constructor.name })
54
59
  this.#limiter = limiter
55
60
  this.#cache = cache
61
+ this.#messageSigner = messageSigner
62
+ this.#policyStorage = policyStorage
56
63
  }
57
64
 
58
65
  async get (url, username = this.#urlFormatter.hostname) {
@@ -129,21 +136,47 @@ export class ActivityPubClient {
129
136
  this.#logger.debug({ headers }, 'Sending headers')
130
137
  const method = 'GET'
131
138
  this.#logger.debug({ url: baseUrl }, 'Signing GET request')
139
+ let storedPolicy, lastPolicy
132
140
  if (sign) {
133
- headers.signature =
141
+ storedPolicy = await this.#policyStorage.get(parsed.origin)
142
+ if (!storedPolicy || storedPolicy === SignaturePolicyStorage.RFC9421) {
143
+ lastPolicy = SignaturePolicyStorage.RFC9421
144
+ const sigHeaders = await this.#messageSign({ username, url: baseUrl, method, headers })
145
+ Object.assign(headers, sigHeaders || {})
146
+ } else if (storedPolicy === SignaturePolicyStorage.DRAFT_CAVAGE_12) {
147
+ lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
148
+ headers.signature =
134
149
  await this.#sign({ username, url: baseUrl, method, headers })
150
+ } else {
151
+ throw new Error(`Unexpected signature policy ${storedPolicy}`)
152
+ }
135
153
  }
136
154
  const hostname = parsed.hostname
137
155
  this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
138
156
  await this.#limiter.limit(hostname)
139
157
  this.#logger.debug({ url: baseUrl }, 'Fetching with GET')
140
- const res = await fetch(baseUrl,
158
+ let res = await fetch(baseUrl,
141
159
  {
142
160
  method,
143
161
  headers
144
162
  }
145
163
  )
146
164
  this.#logger.debug({ hostname, status: res.status }, 'response received')
165
+ if ([401, 403].includes(res.status) &&
166
+ sign &&
167
+ !storedPolicy) {
168
+ lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
169
+ delete headers['signature-input']
170
+ headers.signature =
171
+ await this.#sign({ username, url: baseUrl, method, headers })
172
+ res = await fetch(baseUrl,
173
+ {
174
+ method,
175
+ headers
176
+ }
177
+ )
178
+ }
179
+
147
180
  await this.#limiter.update(hostname, res.headers)
148
181
  this.#logger.debug({ url }, 'Finished GET')
149
182
  if (useCache && res.status === 304) {
@@ -155,8 +188,8 @@ export class ActivityPubClient {
155
188
  { status: res.status, body, url: baseUrl },
156
189
  'Could not fetch url'
157
190
  )
158
- throw createHttpError(
159
- res.status,
191
+ throw new ProblemDetailsError(
192
+ 500,
160
193
  `Could not fetch ${baseUrl}`,
161
194
  { headers: res.headers }
162
195
  )
@@ -176,6 +209,10 @@ export class ActivityPubClient {
176
209
  throw err
177
210
  }
178
211
 
212
+ if (sign && !storedPolicy && lastPolicy) {
213
+ await this.#policyStorage.set(parsed.origin, lastPolicy)
214
+ }
215
+
179
216
  await this.#cache.set(baseUrl, username, json, res.headers)
180
217
 
181
218
  return json
@@ -206,6 +243,7 @@ export class ActivityPubClient {
206
243
  assert.ok(username)
207
244
  assert.equal(typeof username, 'string')
208
245
  assert.ok(username !== '*')
246
+ const parsed = (new URL(url))
209
247
  const json = await obj.export()
210
248
  this.#fixupJson(json)
211
249
  const body = JSON.stringify(json)
@@ -218,28 +256,56 @@ export class ActivityPubClient {
218
256
  const method = 'POST'
219
257
  assert.ok(headers)
220
258
  this.#logger.debug({ url }, 'Signing POST')
221
- headers.signature = await this.#sign({ username, url, method, headers })
222
- const hostname = (new URL(url)).hostname
259
+ let lastPolicy
260
+ const storedPolicy = await this.#policyStorage.get(parsed.origin)
261
+ if (!storedPolicy || storedPolicy === SignaturePolicyStorage.RFC9421) {
262
+ lastPolicy = SignaturePolicyStorage.RFC9421
263
+ const sigHeaders = await this.#messageSign({ username, url, method, headers })
264
+ Object.assign(headers, sigHeaders || {})
265
+ } else if (storedPolicy === SignaturePolicyStorage.DRAFT_CAVAGE_12) {
266
+ lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
267
+ headers.signature =
268
+ await this.#sign({ username, url, method, headers })
269
+ } else {
270
+ throw new Error(`Unexpected signature policy ${storedPolicy}`)
271
+ }
272
+ const hostname = parsed.hostname
223
273
  this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
224
274
  await this.#limiter.limit(hostname)
225
275
  this.#logger.debug({ url }, 'Fetching POST')
226
- const res = await fetch(url,
276
+ let res = await fetch(url,
227
277
  {
228
278
  method,
229
279
  headers,
230
280
  body
231
281
  }
232
282
  )
283
+ if ([401, 403].includes(res.status) && !storedPolicy) {
284
+ lastPolicy = SignaturePolicyStorage.DRAFT_CAVAGE_12
285
+ delete headers['signature-input']
286
+ headers.signature =
287
+ await this.#sign({ username, url, method, headers })
288
+ res = await fetch(url,
289
+ {
290
+ method,
291
+ headers,
292
+ body
293
+ }
294
+ )
295
+ }
233
296
  this.#logger.debug({ hostname }, 'updating limiter')
234
297
  await this.#limiter.update(hostname, res.headers)
235
298
  this.#logger.debug({ url }, 'Done fetching POST')
236
299
  if (res.status < 200 || res.status > 299) {
237
- throw createHttpError(
238
- res.status,
239
- await res.text(),
300
+ throw new ProblemDetailsError(
301
+ 500,
302
+ `Could not post to ${url}`,
240
303
  { headers: res.headers }
241
304
  )
242
305
  }
306
+ if (!storedPolicy && lastPolicy) {
307
+ await this.#policyStorage.set(parsed.origin, lastPolicy)
308
+ }
243
309
  }
244
310
 
245
311
  async #sign ({ username, url, method, headers }) {
@@ -252,6 +318,16 @@ export class ActivityPubClient {
252
318
  return this.#signer.sign({ privateKey, keyId, url, method, headers })
253
319
  }
254
320
 
321
+ async #messageSign ({ username, url, method, headers }) {
322
+ assert.ok(url)
323
+ assert.ok(method)
324
+ assert.ok(headers)
325
+ assert.ok(username)
326
+ const privateKey = await this.#keyStorage.getPrivateKey(username)
327
+ const keyId = this.#urlFormatter.format({ username, type: 'publickey' })
328
+ return this.#messageSigner.sign({ privateKey, keyId, url, method, headers })
329
+ }
330
+
255
331
  #isCollection (obj) {
256
332
  return (Array.isArray(obj.type))
257
333
  ? obj.type.some(item => COLLECTION_TYPES.includes(item))
package/lib/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import http from 'node:http'
2
2
  import { resolve, dirname } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { randomUUID } from 'node:crypto'
4
5
 
5
6
  import { Sequelize } from 'sequelize'
6
7
  import express from 'express'
@@ -45,7 +46,7 @@ import { FanoutWorker } from './fanoutworker.js'
45
46
  import { IntakeWorker } from './intakeworker.js'
46
47
  import { RemoteObjectCache } from './remoteobjectcache.js'
47
48
  import { HTTPMessageSignature } from './httpmessagesignature.js'
48
- import { randomUUID } from 'node:crypto'
49
+ import { SignaturePolicyStorage } from './signaturepolicystorage.js'
49
50
 
50
51
  const currentDir = dirname(fileURLToPath(import.meta.url))
51
52
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
@@ -77,7 +78,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
77
78
  const objectStorage = new ObjectStorage(connection)
78
79
  const limiter = new RateLimiter(connection, logger)
79
80
  const remoteObjectCache = new RemoteObjectCache(connection, logger)
80
- const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache)
81
+ const policyStore = new SignaturePolicyStorage(connection, logger)
82
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache, messageSigner, policyStore)
81
83
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
82
84
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
83
85
  const jobQueue = new JobQueue(connection, logger)
@@ -268,9 +270,16 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
268
270
  } else {
269
271
  logger.debug(err)
270
272
  }
273
+ const type = err.type || 'about:blank'
274
+ const problemTitle = err.title || title
275
+ const extra = Object.fromEntries(
276
+ Object.entries(err).filter(
277
+ ([k]) => !['status', 'type', 'title', 'detail', 'stack'].includes(k)
278
+ )
279
+ )
271
280
  res.status(status)
272
281
  res.type('application/problem+json')
273
- res.json({ type: 'about:blank', title, status, detail: err.message })
282
+ res.json({ type, title: problemTitle, status, detail: err.message, ...extra })
274
283
  })
275
284
 
276
285
  app.onIdle = async () => {
package/lib/errors.js ADDED
@@ -0,0 +1,158 @@
1
+ import http from 'node:http'
2
+
3
+ const FEP_C180 = 'https://w3id.org/fep/c180'
4
+
5
+ export class ProblemDetailsError extends Error {
6
+ constructor (status, detail, extra = {}) {
7
+ super(detail)
8
+ this.status = status
9
+ const { type, title, ...rest } = extra
10
+ this.type = type || 'about:blank'
11
+ this.title = title || http.STATUS_CODES[status] || 'Unknown Status'
12
+ this.detail = detail
13
+ Object.assign(this, rest)
14
+ }
15
+ }
16
+
17
+ export class UnsupportedTypeError extends ProblemDetailsError {
18
+ constructor (detail, extra = {}) {
19
+ // Rename `type` to `objectType` to avoid conflict with the problem type URI field
20
+ const { type: objectType, ...rest } = extra
21
+ super(400, detail, {
22
+ type: `${FEP_C180}#unsupported-type`,
23
+ title: 'Unsupported type',
24
+ ...rest,
25
+ ...(objectType != null ? { objectType } : {})
26
+ })
27
+ }
28
+ }
29
+
30
+ export class ObjectDoesNotExistError extends ProblemDetailsError {
31
+ constructor (detail, extra = {}) {
32
+ super(400, detail, {
33
+ type: `${FEP_C180}#object-does-not-exist`,
34
+ title: 'Object does not exist',
35
+ ...extra
36
+ })
37
+ }
38
+ }
39
+
40
+ export class DuplicateDeliveryError extends ProblemDetailsError {
41
+ constructor (detail, extra = {}) {
42
+ super(400, detail, {
43
+ type: `${FEP_C180}#duplicate-delivery`,
44
+ title: 'Duplicate delivery',
45
+ ...extra
46
+ })
47
+ }
48
+ }
49
+
50
+ export class RedundantActivityError extends ProblemDetailsError {
51
+ constructor (detail, extra = {}) {
52
+ super(400, detail, {
53
+ type: `${FEP_C180}#redundant-activity`,
54
+ title: 'Redundant activity',
55
+ ...extra
56
+ })
57
+ }
58
+ }
59
+
60
+ export class ApprovalRequiredError extends ProblemDetailsError {
61
+ constructor (detail, extra = {}) {
62
+ super(202, detail, {
63
+ type: `${FEP_C180}#approval-required`,
64
+ title: 'Approval required',
65
+ ...extra
66
+ })
67
+ }
68
+ }
69
+
70
+ export class NotAnActorError extends ProblemDetailsError {
71
+ constructor (detail, extra = {}) {
72
+ super(400, detail, {
73
+ type: `${FEP_C180}#not-an-actor`,
74
+ title: 'Not an actor',
75
+ ...extra
76
+ })
77
+ }
78
+ }
79
+
80
+ export class PrincipalActorMismatchError extends ProblemDetailsError {
81
+ constructor (detail, extra = {}) {
82
+ super(400, detail, {
83
+ type: `${FEP_C180}#principal-actor-mismatch`,
84
+ title: 'Principal-actor mismatch',
85
+ ...extra
86
+ })
87
+ }
88
+ }
89
+
90
+ export class ActorNotAuthorizedError extends ProblemDetailsError {
91
+ constructor (detail, extra = {}) {
92
+ super(403, detail, {
93
+ type: `${FEP_C180}#actor-not-authorized`,
94
+ title: 'Actor not authorized',
95
+ ...extra
96
+ })
97
+ }
98
+ }
99
+
100
+ export class PrincipalNotAuthorizedError extends ProblemDetailsError {
101
+ constructor (detail, extra = {}) {
102
+ super(403, detail, {
103
+ type: `${FEP_C180}#principal-not-authorized`,
104
+ title: 'Principal not authorized',
105
+ ...extra
106
+ })
107
+ }
108
+ }
109
+
110
+ export class ClientNotAuthorizedError extends ProblemDetailsError {
111
+ constructor (detail, extra = {}) {
112
+ super(403, detail, {
113
+ type: `${FEP_C180}#client-not-authorized`,
114
+ title: 'Client not authorized',
115
+ ...extra
116
+ })
117
+ }
118
+ }
119
+
120
+ export class UnsupportedMediaTypeError extends ProblemDetailsError {
121
+ constructor (detail, extra = {}) {
122
+ super(400, detail, {
123
+ type: `${FEP_C180}#unsupported-media-type`,
124
+ title: 'Unsupported media type',
125
+ ...extra
126
+ })
127
+ }
128
+ }
129
+
130
+ export class MediaTooLargeError extends ProblemDetailsError {
131
+ constructor (detail, extra = {}) {
132
+ super(413, detail, {
133
+ type: `${FEP_C180}#media-too-large`,
134
+ title: 'Media too large',
135
+ ...extra
136
+ })
137
+ }
138
+ }
139
+
140
+ export class NoApplicableAddresseesError extends ProblemDetailsError {
141
+ constructor (detail, extra = {}) {
142
+ super(400, detail, {
143
+ type: `${FEP_C180}#no-applicable-addressees`,
144
+ title: 'No applicable addressees',
145
+ ...extra
146
+ })
147
+ }
148
+ }
149
+
150
+ export class RateLimitExceededError extends ProblemDetailsError {
151
+ constructor (detail, extra = {}) {
152
+ super(429, detail, {
153
+ type: `${FEP_C180}#rate-limit-exceeded`,
154
+ title: 'Rate limit exceeded',
155
+ ...extra
156
+ })
157
+ }
158
+ }
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto'
2
2
  import assert from 'node:assert'
3
3
 
4
- import createHttpError from 'http-errors'
4
+ import { ProblemDetailsError } from './errors.js'
5
5
 
6
6
  export class HTTPSignature {
7
7
  static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
@@ -13,48 +13,48 @@ export class HTTPSignature {
13
13
  keyId (signature) {
14
14
  const params = this.#parseSignatureHeader(signature)
15
15
  if (!params.keyId) {
16
- throw createHttpError(401, 'No keyId provided')
16
+ throw new ProblemDetailsError(401, 'No keyId provided')
17
17
  }
18
18
  return params.keyId
19
19
  }
20
20
 
21
21
  async validate (publicKeyPem, signature, method, path, headers) {
22
22
  if (!signature) {
23
- throw createHttpError(401, 'No signature provided')
23
+ throw new ProblemDetailsError(401, 'No signature provided')
24
24
  }
25
25
  if (!method) {
26
- throw createHttpError(400, 'No HTTP method provided')
26
+ throw new ProblemDetailsError(400, 'No HTTP method provided')
27
27
  }
28
28
  if (!path) {
29
- throw createHttpError(400, 'No URL path provided')
29
+ throw new ProblemDetailsError(400, 'No URL path provided')
30
30
  }
31
31
  if (!headers) {
32
- throw createHttpError(400, 'No request headers provided')
32
+ throw new ProblemDetailsError(400, 'No request headers provided')
33
33
  }
34
34
 
35
35
  const params = this.#parseSignatureHeader(signature)
36
36
 
37
37
  const keyId = params.keyId
38
38
  if (!keyId) {
39
- throw createHttpError(401, 'No keyId provided')
39
+ throw new ProblemDetailsError(401, 'No keyId provided')
40
40
  }
41
41
  this.#logger.debug({ keyId }, 'validating signature')
42
42
  const algorithm = params.algorithm
43
43
  if (!algorithm) {
44
- throw createHttpError(401, 'No algorithm provided')
44
+ throw new ProblemDetailsError(401, 'No algorithm provided')
45
45
  }
46
46
  this.#logger.debug({ algorithm }, 'validating signature')
47
47
  if (!(algorithm === 'rsa-sha256' || (algorithm === 'hs2019' && this.#isRSAKey(publicKeyPem)))) {
48
- throw createHttpError(401, 'Only rsa-sha256 or hs2019 with RSA supported')
48
+ throw new ProblemDetailsError(401, 'Only rsa-sha256 or hs2019 with RSA supported')
49
49
  }
50
50
  if (!params.headers) {
51
- throw createHttpError(401, 'No headers provided')
51
+ throw new ProblemDetailsError(401, 'No headers provided')
52
52
  }
53
53
  const signedHeaders = params.headers.split(' ')
54
54
  this.#logger.debug({ signedHeaders }, 'validating signature')
55
55
  const signatureString = params.signature
56
56
  if (!signatureString) {
57
- throw createHttpError(401, 'No signature field provided in signature header')
57
+ throw new ProblemDetailsError(401, 'No signature field provided in signature header')
58
58
  }
59
59
  this.#logger.debug({ signatureString }, 'validating signature')
60
60
  const signingString = this.#signingString({
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
 
3
- import createHttpError from 'http-errors'
3
+ import { ProblemDetailsError } from './errors.js'
4
4
 
5
5
  import BotMaker from './botmaker.js'
6
6
 
@@ -77,21 +77,21 @@ export class HTTPSignatureAuthenticator {
77
77
  this.#logger.debug({ reqId: req.id, signature }, 'Got signed request')
78
78
  const date = req.get('Date')
79
79
  if (!date) {
80
- throw createHttpError(400, 'No date provided')
80
+ throw new ProblemDetailsError(400, 'No date provided')
81
81
  }
82
82
  if (Math.abs(Date.parse(date) - Date.now()) >
83
83
  HTTPSignatureAuthenticator.#maxDateDiff) {
84
- throw createHttpError(400, 'Time skew too large')
84
+ throw new ProblemDetailsError(400, 'Time skew too large')
85
85
  }
86
86
  if (req.rawBodyText && req.rawBodyText.length > 0) {
87
87
  const digest = req.get('Digest')
88
88
  if (!digest) {
89
- throw createHttpError(400, 'No digest provided')
89
+ throw new ProblemDetailsError(400, 'No digest provided')
90
90
  }
91
91
  const calculated = await this.#digester.digest(req.rawBodyText)
92
92
  if (!this.#digester.equals(digest, calculated)) {
93
93
  this.#logger.debug({ reqId: req.id, calculated, digest }, 'Digest mismatch')
94
- throw createHttpError(400, 'Digest mismatch')
94
+ throw new ProblemDetailsError(400, 'Digest mismatch')
95
95
  }
96
96
  }
97
97
  const { method, headers } = req
@@ -100,7 +100,7 @@ export class HTTPSignatureAuthenticator {
100
100
  this.#logger.debug({ reqId: req.id, keyId }, 'Signed with keyId')
101
101
  const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
102
102
  if (!ok) {
103
- throw createHttpError(400, 'public key not found')
103
+ throw new ProblemDetailsError(400, 'public key not found')
104
104
  }
105
105
  let owner = ok.owner
106
106
  let publicKeyPem = ok.publicKeyPem
@@ -125,7 +125,7 @@ export class HTTPSignatureAuthenticator {
125
125
  req.auth.subject = owner
126
126
  return next()
127
127
  } else {
128
- throw createHttpError(401, 'Unauthorized')
128
+ throw new ProblemDetailsError(401, 'Unauthorized')
129
129
  }
130
130
  }
131
131
 
@@ -137,25 +137,25 @@ export class HTTPSignatureAuthenticator {
137
137
  const date = req.get('Date')
138
138
  if (date && Math.abs(Date.parse(date) - Date.now()) >
139
139
  HTTPSignatureAuthenticator.#maxDateDiff) {
140
- throw createHttpError(400, 'Time skew too large')
140
+ throw new ProblemDetailsError(400, 'Time skew too large')
141
141
  }
142
142
  if (req.rawBodyText && req.rawBodyText.length > 0) {
143
143
  const digest = req.get('Content-Digest')
144
144
  if (!digest) {
145
- throw createHttpError(400, 'No digest provided')
145
+ throw new ProblemDetailsError(400, 'No digest provided')
146
146
  }
147
147
  const calculated = await this.#digester.contentDigest(req.rawBodyText)
148
148
  if (!this.#digester.equals(digest, calculated)) {
149
149
  this.#logger.debug({ reqId: req.id, calculated, digest }, 'Digest mismatch')
150
- throw createHttpError(400, 'Digest mismatch')
150
+ throw new ProblemDetailsError(400, 'Digest mismatch')
151
151
  }
152
152
  }
153
153
  const created = this.#messageSigner.created(signatureInput)
154
154
  if (!created) {
155
- throw createHttpError(400, 'No created timestamp provided')
155
+ throw new ProblemDetailsError(400, 'No created timestamp provided')
156
156
  }
157
157
  if (Math.abs(Date.now() - created * 1000) > HTTPSignatureAuthenticator.#maxDateDiff) {
158
- throw createHttpError(400, 'Time skew too large')
158
+ throw new ProblemDetailsError(400, 'Time skew too large')
159
159
  }
160
160
  const { method, headers } = req
161
161
  const { origin } = req.app.locals
@@ -163,12 +163,12 @@ export class HTTPSignatureAuthenticator {
163
163
 
164
164
  const keyId = this.#messageSigner.keyId(signatureInput)
165
165
  if (!keyId) {
166
- throw createHttpError(400, 'no public key provided')
166
+ throw new ProblemDetailsError(400, 'no public key provided')
167
167
  }
168
168
  this.#logger.debug({ reqId: req.id, keyId }, 'Signed with keyId')
169
169
  const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
170
170
  if (!ok) {
171
- throw createHttpError(400, 'public key not found')
171
+ throw new ProblemDetailsError(400, 'public key not found')
172
172
  }
173
173
  let owner = ok.owner
174
174
  let publicKeyPem = ok.publicKeyPem
@@ -193,7 +193,7 @@ export class HTTPSignatureAuthenticator {
193
193
  req.auth.subject = owner
194
194
  return next()
195
195
  } else {
196
- throw createHttpError(401, 'Unauthorized')
196
+ throw new ProblemDetailsError(401, 'Unauthorized')
197
197
  }
198
198
  }
199
199
  }
@@ -0,0 +1,16 @@
1
+ export const id = '008-signature-policy'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ CREATE TABLE signature_policy (
6
+ origin varchar(256) NOT NULL PRIMARY KEY,
7
+ policy varchar(32) NOT NULL,
8
+ expiry TIMESTAMP NOT NULL,
9
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
11
+ );
12
+ `, queryOptions)
13
+ await connection.query(`
14
+ CREATE INDEX signature_policy_expiry on signature_policy (expiry);
15
+ `, queryOptions)
16
+ }
@@ -1,10 +1,10 @@
1
1
  import assert from 'node:assert'
2
2
 
3
3
  import express from 'express'
4
- import createHttpError from 'http-errors'
5
4
 
6
5
  import as2 from '../activitystreams.js'
7
6
  import BotMaker from '../botmaker.js'
7
+ import { ProblemDetailsError, PrincipalNotAuthorizedError } from '../errors.js'
8
8
 
9
9
  const collections = ['outbox', 'liked', 'followers', 'following']
10
10
  const router = express.Router()
@@ -40,7 +40,7 @@ function collectionHandler (collection) {
40
40
  const { actorStorage, bots } = req.app.locals
41
41
  const bot = await BotMaker.makeBot(bots, username)
42
42
  if (!bot) {
43
- return next(createHttpError(404, `User ${username} not found`))
43
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
44
44
  }
45
45
  const coll = await actorStorage.getCollection(username, collection)
46
46
  res.status(200)
@@ -57,20 +57,20 @@ function collectionPageHandler (collection) {
57
57
  try {
58
58
  pageNo = parseInt(n)
59
59
  } catch (err) {
60
- return next(createHttpError(400, `Invalid page ${n}`))
60
+ return next(new ProblemDetailsError(400, `Invalid page ${n}`))
61
61
  }
62
62
  const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
63
63
  const bot = await BotMaker.makeBot(bots, username)
64
64
 
65
65
  if (!bot) {
66
- return next(createHttpError(404, `User ${username} not found`))
66
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
67
67
  }
68
68
 
69
69
  if (collection === 'inbox') {
70
- return next(createHttpError(403, `No access to ${collection} collection`))
70
+ return next(new PrincipalNotAuthorizedError(`No access to ${collection} collection`, { resource: req.url }))
71
71
  }
72
72
  if (!await actorStorage.hasPage(username, collection, parseInt(n))) {
73
- return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`))
73
+ return next(new ProblemDetailsError(404, `No such page ${n} for collection ${collection} for user ${username}`))
74
74
  }
75
75
 
76
76
  let exported = null
@@ -113,7 +113,7 @@ function collectionPageHandler (collection) {
113
113
  { err: error, username, collection, n },
114
114
  'error loading collection page'
115
115
  )
116
- return next(createHttpError(500, 'Error loading collection page'))
116
+ return next(new ProblemDetailsError(500, 'Error loading collection page'))
117
117
  }
118
118
  res.status(200)
119
119
  res.type(as2.mediaType)
@@ -1,5 +1,5 @@
1
1
  import express from 'express'
2
- import createHttpError from 'http-errors'
2
+ import { ProblemDetailsError } from '../errors.js'
3
3
 
4
4
  const router = express.Router()
5
5
 
@@ -17,7 +17,7 @@ router.get('/readyz', async (req, res, next) => {
17
17
  res.type('text/plain')
18
18
  res.end('OK')
19
19
  } catch (err) {
20
- return next(createHttpError(503, 'Service Unavailable'))
20
+ return next(new ProblemDetailsError(503, 'Service Unavailable'))
21
21
  }
22
22
  })
23
23
 
@@ -1,10 +1,17 @@
1
1
  import http from 'node:http'
2
2
 
3
3
  import express from 'express'
4
- import createHttpError from 'http-errors'
5
4
 
6
5
  import as2 from '../activitystreams.js'
7
6
  import BotMaker from '../botmaker.js'
7
+ import {
8
+ ProblemDetailsError,
9
+ UnsupportedTypeError,
10
+ DuplicateDeliveryError,
11
+ PrincipalActorMismatchError,
12
+ ActorNotAuthorizedError,
13
+ PrincipalNotAuthorizedError
14
+ } from '../errors.js'
8
15
 
9
16
  const router = express.Router()
10
17
 
@@ -16,15 +23,15 @@ router.post('/user/:username/inbox', async (req, res, next) => {
16
23
 
17
24
  const bot = await BotMaker.makeBot(bots, username)
18
25
  if (!bot) {
19
- return next(createHttpError(404, `User ${username} not found`))
26
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
20
27
  }
21
28
 
22
29
  if (!subject) {
23
- return next(createHttpError(401, 'Unauthorized'))
30
+ return next(new ProblemDetailsError(401, 'Unauthorized'))
24
31
  }
25
32
 
26
33
  if (!req.body) {
27
- return next(createHttpError(400, 'No request body provided'))
34
+ return next(new ProblemDetailsError(400, 'No request body provided'))
28
35
  }
29
36
 
30
37
  let activity
@@ -34,37 +41,37 @@ router.post('/user/:username/inbox', async (req, res, next) => {
34
41
  } catch (err) {
35
42
  logger.warn({ reqId: req.id, err }, 'Failed to import activity')
36
43
  logger.debug({ reqId: req.id, body: req.body }, 'Request body')
37
- return next(createHttpError(400, 'Invalid request body'))
44
+ return next(new ProblemDetailsError(400, 'Invalid request body'))
38
45
  }
39
46
 
40
47
  if (!activity.isActivity()) {
41
- return next(createHttpError(400, 'Request body is not an activity'))
48
+ return next(new UnsupportedTypeError('Request body is not an activity', { objectType: activity.type }))
42
49
  }
43
50
 
44
51
  const actor = deliverer.getActor(activity)
45
52
 
46
53
  if (!actor) {
47
- return next(createHttpError(400, 'No actor found in activity'))
54
+ return next(new ProblemDetailsError(400, 'No actor found in activity'))
48
55
  }
49
56
 
50
57
  if (!actor.id) {
51
- return next(createHttpError(400, 'No actor id found in activity'))
58
+ return next(new ProblemDetailsError(400, 'No actor id found in activity'))
52
59
  }
53
60
 
54
61
  if (actor.id !== subject && !(await bot.actorOK(subject, activity))) {
55
- return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
62
+ return next(new PrincipalActorMismatchError(`${subject} is not the actor ${actor.id}`, { principal: subject, actor: actor.id }))
56
63
  }
57
64
 
58
65
  if (await actorStorage.isInCollection(username, 'blocked', actor)) {
59
- return next(createHttpError(403, 'Forbidden'))
66
+ return next(new ActorNotAuthorizedError('Blocked actor', { actor: actor.id, resource: req.url }))
60
67
  }
61
68
 
62
69
  if (!activity.id) {
63
- return next(createHttpError(400, 'No activity id found in activity'))
70
+ return next(new ProblemDetailsError(400, 'No activity id found in activity'))
64
71
  }
65
72
 
66
73
  if (await actorStorage.isInCollection(bot.username, 'inbox', activity)) {
67
- return next(createHttpError(400, 'Activity already delivered'))
74
+ return next(new DuplicateDeliveryError('Activity already delivered', { id: activity.id }))
68
75
  }
69
76
 
70
77
  logger.info(
@@ -84,11 +91,11 @@ router.post('/user/:username/inbox', async (req, res, next) => {
84
91
  })
85
92
 
86
93
  router.get('/user/:username/inbox', async (req, res, next) => {
87
- return next(createHttpError(403, 'No access to inbox collection'))
94
+ return next(new PrincipalNotAuthorizedError('No access to inbox collection', { resource: req.url }))
88
95
  })
89
96
 
90
97
  router.get('/user/:username/inbox/:n', async (req, res, next) => {
91
- return next(createHttpError(403, 'No access to inbox collection'))
98
+ return next(new PrincipalNotAuthorizedError('No access to inbox collection', { resource: req.url }))
92
99
  })
93
100
 
94
101
  export default router
@@ -1,7 +1,7 @@
1
1
  import express from 'express'
2
- import createHttpError from 'http-errors'
3
2
 
4
3
  import as2 from '../activitystreams.js'
4
+ import { ProblemDetailsError, PrincipalNotAuthorizedError } from '../errors.js'
5
5
 
6
6
  const router = express.Router()
7
7
 
@@ -10,17 +10,17 @@ export default router
10
10
  router.get('/user/:username/:type/:nanoid', async (req, res, next) => {
11
11
  const { username, type, nanoid } = req.params
12
12
  if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
13
- return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
13
+ return next(new ProblemDetailsError(400, `Invalid nanoid ${nanoid}`))
14
14
  }
15
15
  const { objectStorage, formatter, authorizer } = req.app.locals
16
16
  const id = formatter.format({ username, type, nanoid })
17
17
  const object = await objectStorage.read(id)
18
18
  if (!object) {
19
- return next(createHttpError(404, `Object ${id} not found`))
19
+ return next(new ProblemDetailsError(404, `Object ${id} not found`))
20
20
  }
21
21
  const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
22
22
  if (!await authorizer.canRead(remote, object)) {
23
- return next(createHttpError(403, `Forbidden to read object ${id}`))
23
+ return next(new PrincipalNotAuthorizedError('Forbidden to read object', { id }))
24
24
  }
25
25
  res.status(200)
26
26
  res.type(as2.mediaType)
@@ -30,20 +30,20 @@ router.get('/user/:username/:type/:nanoid', async (req, res, next) => {
30
30
  router.get('/user/:username/:type/:nanoid/:collection', async (req, res, next) => {
31
31
  const { objectStorage, formatter, authorizer } = req.app.locals
32
32
  if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
33
- return next(createHttpError(404, 'Not Found'))
33
+ return next(new ProblemDetailsError(404, 'Not Found'))
34
34
  }
35
35
  const { username, type, nanoid } = req.params
36
36
  if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
37
- return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
37
+ return next(new ProblemDetailsError(400, `Invalid nanoid ${nanoid}`))
38
38
  }
39
39
  const id = formatter.format({ username, type, nanoid })
40
40
  const object = await objectStorage.read(id)
41
41
  if (!object) {
42
- return next(createHttpError(404, 'Not Found'))
42
+ return next(new ProblemDetailsError(404, 'Not Found'))
43
43
  }
44
44
  const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
45
45
  if (!await authorizer.canRead(remote, object)) {
46
- return next(createHttpError(403, 'Forbidden'))
46
+ return next(new ProblemDetailsError(403, 'Forbidden'))
47
47
  }
48
48
  const collection = await objectStorage.getCollection(id, req.params.collection)
49
49
  res.status(200)
@@ -57,23 +57,23 @@ router.get('/user/:username/:type/:nanoid/:collection/:n', async (req, res, next
57
57
  try {
58
58
  pageNo = parseInt(n)
59
59
  } catch (err) {
60
- return next(createHttpError(400, `Invalid page ${n}`))
60
+ return next(new ProblemDetailsError(400, `Invalid page ${n}`))
61
61
  }
62
62
  if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
63
- return next(createHttpError(400, `Invalid nanoid ${nanoid}`))
63
+ return next(new ProblemDetailsError(400, `Invalid nanoid ${nanoid}`))
64
64
  }
65
65
  const { objectStorage, formatter, authorizer } = req.app.locals
66
66
  if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
67
- return next(createHttpError(404, 'Not Found'))
67
+ return next(new ProblemDetailsError(404, 'Not Found'))
68
68
  }
69
69
  const id = formatter.format({ username, type, nanoid })
70
70
  const object = await objectStorage.read(id)
71
71
  if (!object) {
72
- return next(createHttpError(404, 'Not Found'))
72
+ return next(new ProblemDetailsError(404, 'Not Found'))
73
73
  }
74
74
  const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
75
75
  if (!await authorizer.canRead(remote, object)) {
76
- return next(createHttpError(403, 'Forbidden'))
76
+ return next(new ProblemDetailsError(403, 'Forbidden'))
77
77
  }
78
78
  const collectionPage = await objectStorage.getCollectionPage(
79
79
  id,
@@ -1,5 +1,5 @@
1
1
  import express from 'express'
2
- import createHttpError from 'http-errors'
2
+ import { ProblemDetailsError } from '../errors.js'
3
3
  import BotMaker from '../botmaker.js'
4
4
 
5
5
  const router = express.Router()
@@ -9,7 +9,7 @@ router.get('/profile/:username', async (req, res, next) => {
9
9
  const { profileFileName, bots } = req.app.locals
10
10
  const bot = await BotMaker.makeBot(bots, username)
11
11
  if (!bot) {
12
- return next(createHttpError(404, `User ${username} not found`))
12
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
13
13
  }
14
14
  res.type('html')
15
15
  res.sendFile(profileFileName)
@@ -1,7 +1,7 @@
1
1
  import express from 'express'
2
2
 
3
3
  import as2 from '../activitystreams.js'
4
- import createHttpError from 'http-errors'
4
+ import { ProblemDetailsError } from '../errors.js'
5
5
 
6
6
  const router = express.Router()
7
7
 
@@ -17,7 +17,7 @@ router.post('/shared/proxy', async (req, res, next) => {
17
17
  const { id } = req.body
18
18
 
19
19
  if (!id) {
20
- return next(createHttpError(400, 'Missing id parameter'))
20
+ return next(new ProblemDetailsError(400, 'Missing id parameter'))
21
21
  }
22
22
 
23
23
  let url
@@ -25,11 +25,11 @@ router.post('/shared/proxy', async (req, res, next) => {
25
25
  try {
26
26
  url = new URL(id)
27
27
  } catch (error) {
28
- return next(createHttpError(400, 'id must be an URL'))
28
+ return next(new ProblemDetailsError(400, 'id must be an URL'))
29
29
  }
30
30
 
31
31
  if (url.protocol !== 'https:') {
32
- return next(createHttpError(400, 'id must be an https: URL'))
32
+ return next(new ProblemDetailsError(400, 'id must be an https: URL'))
33
33
  }
34
34
 
35
35
  let obj
@@ -38,7 +38,7 @@ router.post('/shared/proxy', async (req, res, next) => {
38
38
  obj = await client.get(id)
39
39
  } catch (err) {
40
40
  logger.warn({ reqId: req.id, err, id }, 'Error fetching object in proxy')
41
- return next(createHttpError(400, `Error fetching object ${id}`))
41
+ return next(new ProblemDetailsError(400, `Error fetching object ${id}`))
42
42
  }
43
43
 
44
44
  res.status(200)
@@ -1,9 +1,13 @@
1
1
  import http from 'node:http'
2
2
 
3
3
  import express from 'express'
4
- import createHttpError from 'http-errors'
5
4
 
6
5
  import as2 from '../activitystreams.js'
6
+ import {
7
+ ProblemDetailsError,
8
+ UnsupportedTypeError,
9
+ PrincipalActorMismatchError
10
+ } from '../errors.js'
7
11
 
8
12
  const router = express.Router()
9
13
 
@@ -28,11 +32,11 @@ router.post('/shared/inbox', async (req, res, next) => {
28
32
  const { subject } = req.auth
29
33
 
30
34
  if (!subject) {
31
- return next(createHttpError(401, 'Unauthorized'))
35
+ return next(new ProblemDetailsError(401, 'Unauthorized'))
32
36
  }
33
37
 
34
38
  if (!req.body) {
35
- return next(createHttpError(400, 'No request body provided'))
39
+ return next(new ProblemDetailsError(400, 'No request body provided'))
36
40
  }
37
41
 
38
42
  let activity
@@ -42,29 +46,29 @@ router.post('/shared/inbox', async (req, res, next) => {
42
46
  } catch (err) {
43
47
  logger.warn({ reqId: req.id, err }, 'Failed to import activity')
44
48
  logger.debug({ reqId: req.id, body: req.body }, 'Request body')
45
- return next(createHttpError(400, 'Invalid request body'))
49
+ return next(new ProblemDetailsError(400, 'Invalid request body'))
46
50
  }
47
51
 
48
52
  if (!activity.isActivity()) {
49
- return next(createHttpError(400, 'Request body is not an activity'))
53
+ return next(new UnsupportedTypeError('Request body is not an activity', { objectType: activity.type }))
50
54
  }
51
55
 
52
56
  const actor = deliverer.getActor(activity)
53
57
 
54
58
  if (!actor) {
55
- return next(createHttpError(400, 'No actor found in activity'))
59
+ return next(new ProblemDetailsError(400, 'No actor found in activity'))
56
60
  }
57
61
 
58
62
  if (!actor.id) {
59
- return next(createHttpError(400, 'No actor id found in activity'))
63
+ return next(new ProblemDetailsError(400, 'No actor id found in activity'))
60
64
  }
61
65
 
62
66
  if (actor.id !== subject && !(await actorOK(subject, activity, bots))) {
63
- return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
67
+ return next(new PrincipalActorMismatchError(`${subject} is not the actor ${actor.id}`, { principal: subject, actor: actor.id }))
64
68
  }
65
69
 
66
70
  if (!activity.id) {
67
- return next(createHttpError(400, 'No activity id found in activity'))
71
+ return next(new ProblemDetailsError(400, 'No activity id found in activity'))
68
72
  }
69
73
 
70
74
  logger.info(
@@ -1,7 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url'
2
2
  import express from 'express'
3
3
  import as2 from '../activitystreams.js'
4
- import createHttpError from 'http-errors'
4
+ import { ProblemDetailsError } from '../errors.js'
5
5
  import BotMaker from '../botmaker.js'
6
6
 
7
7
  const router = express.Router()
@@ -28,7 +28,7 @@ router.get('/user/:username', async (req, res, next) => {
28
28
  const { actorStorage, keyStorage, formatter, bots, origin } = req.app.locals
29
29
  const bot = await BotMaker.makeBot(bots, username)
30
30
  if (!bot) {
31
- return next(createHttpError(404, `User ${username} not found`))
31
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
32
32
  }
33
33
  const publicKeyPem = await keyStorage.getPublicKey(username)
34
34
  const acct = formatter.acct(username)
@@ -73,7 +73,7 @@ router.get('/user/:username/publickey', async (req, res, next) => {
73
73
  const { formatter, keyStorage, bots } = req.app.locals
74
74
  const bot = await BotMaker.makeBot(bots, username)
75
75
  if (!bot) {
76
- return next(createHttpError(404, `User ${username} not found`))
76
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
77
77
  }
78
78
  const publicKeyPem = await keyStorage.getPublicKey(username)
79
79
  const publicKey = await as2.import({
@@ -100,14 +100,14 @@ router.get('/user/:username/icon', async (req, res, next) => {
100
100
  const { bots } = req.app.locals
101
101
  const bot = await BotMaker.makeBot(bots, username)
102
102
  if (!bot) {
103
- return next(createHttpError(404, `User ${username} not found`))
103
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
104
104
  }
105
105
  if (!bot.icon) {
106
- return next(createHttpError(404, `No icon for ${username} found`))
106
+ return next(new ProblemDetailsError(404, `No icon for ${username} found`))
107
107
  }
108
108
 
109
109
  if (typeof bot.icon !== 'object' || !(bot.icon instanceof URL)) {
110
- return next(createHttpError(500, 'Incorrect image format from bot'))
110
+ return next(new ProblemDetailsError(500, 'Incorrect image format from bot'))
111
111
  }
112
112
 
113
113
  if (bot.icon.protocol === 'file:') {
@@ -122,14 +122,14 @@ router.get('/user/:username/image', async (req, res, next) => {
122
122
  const { bots } = req.app.locals
123
123
  const bot = await BotMaker.makeBot(bots, username)
124
124
  if (!bot) {
125
- return next(createHttpError(404, `User ${username} not found`))
125
+ return next(new ProblemDetailsError(404, `User ${username} not found`))
126
126
  }
127
127
  if (!bot.image) {
128
- return next(createHttpError(404, `No image for ${username} found`))
128
+ return next(new ProblemDetailsError(404, `No image for ${username} found`))
129
129
  }
130
130
 
131
131
  if (typeof bot.image !== 'object' || !(bot.image instanceof URL)) {
132
- return next(createHttpError(500, 'Incorrect image format from bot'))
132
+ return next(new ProblemDetailsError(500, 'Incorrect image format from bot'))
133
133
  }
134
134
 
135
135
  if (bot.image.protocol === 'file:') {
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert'
2
2
 
3
3
  import { Router } from 'express'
4
- import createHttpError from 'http-errors'
4
+ import { ProblemDetailsError } from '../errors.js'
5
5
 
6
6
  import BotMaker from '../botmaker.js'
7
7
 
@@ -11,7 +11,7 @@ async function botWebfinger (username, req, res, next) {
11
11
  const { formatter, bots } = req.app.locals
12
12
  const bot = await BotMaker.makeBot(bots, username)
13
13
  if (!bot) {
14
- return next(createHttpError(404, `No such bot '${username}'`))
14
+ return next(new ProblemDetailsError(404, `No such bot '${username}'`))
15
15
  }
16
16
  res.status(200)
17
17
  res.type('application/jrd+json')
@@ -37,7 +37,7 @@ async function profileWebfinger (username, profileUrl, req, res, next) {
37
37
  const { formatter, bots } = req.app.locals
38
38
  const bot = await BotMaker.makeBot(bots, username)
39
39
  if (!bot) {
40
- return next(createHttpError(404, `No such bot '${username}'`))
40
+ return next(new ProblemDetailsError(404, `No such bot '${username}'`))
41
41
  }
42
42
  res.status(200)
43
43
  res.type('application/jrd+json')
@@ -57,7 +57,7 @@ async function httpsWebfinger (resource, req, res, next) {
57
57
  const { formatter } = req.app.locals
58
58
  assert.ok(formatter)
59
59
  if (!formatter.isLocal(resource)) {
60
- return next(createHttpError(400, 'Only local URLs'))
60
+ return next(new ProblemDetailsError(400, 'Only local URLs'))
61
61
  }
62
62
  const parts = formatter.unformat(resource)
63
63
  if (parts.username && !parts.type && !parts.collection) {
@@ -65,18 +65,18 @@ async function httpsWebfinger (resource, req, res, next) {
65
65
  } else if (parts.username && parts.type === 'profile') {
66
66
  return await profileWebfinger(parts.username, resource, req, res, next)
67
67
  } else {
68
- return next(createHttpError(400, `No webfinger lookup for url ${resource}`))
68
+ return next(new ProblemDetailsError(400, `No webfinger lookup for url ${resource}`))
69
69
  }
70
70
  }
71
71
 
72
72
  async function acctWebfinger (resource, req, res, next) {
73
73
  const [username, domain] = resource.substring(5).split('@')
74
74
  if (!username || !domain) {
75
- return next(createHttpError(400, `Invalid resource parameter ${resource}`))
75
+ return next(new ProblemDetailsError(400, `Invalid resource parameter ${resource}`))
76
76
  }
77
77
  const { host } = new URL(req.app.locals.origin)
78
78
  if (domain !== host) {
79
- return next(createHttpError(400, `Invalid domain ${domain} in resource parameter`))
79
+ return next(new ProblemDetailsError(400, `Invalid domain ${domain} in resource parameter`))
80
80
  }
81
81
  return await botWebfinger(username, req, res, next)
82
82
  }
@@ -84,7 +84,7 @@ async function acctWebfinger (resource, req, res, next) {
84
84
  router.get('/.well-known/webfinger', async (req, res, next) => {
85
85
  const { resource } = req.query
86
86
  if (!resource) {
87
- return next(createHttpError(400, 'resource parameter is required'))
87
+ return next(new ProblemDetailsError(400, 'resource parameter is required'))
88
88
  }
89
89
  const colon = resource.indexOf(':')
90
90
  const protocol = resource.slice(0, colon)
@@ -96,7 +96,7 @@ router.get('/.well-known/webfinger', async (req, res, next) => {
96
96
  return await httpsWebfinger(resource, req, res, next)
97
97
  default:
98
98
  return next(
99
- createHttpError(400, `Unsupported resource protocol '${protocol}'`)
99
+ new ProblemDetailsError(400, `Unsupported resource protocol '${protocol}'`)
100
100
  )
101
101
  }
102
102
  })
@@ -0,0 +1,61 @@
1
+ import assert from 'node:assert'
2
+
3
+ export class SignaturePolicyStorage {
4
+ static RFC9421 = 'rfc9421'
5
+ static DRAFT_CAVAGE_12 = 'draft-cavage-12'
6
+ static #policies = [
7
+ SignaturePolicyStorage.RFC9421,
8
+ SignaturePolicyStorage.DRAFT_CAVAGE_12
9
+ ]
10
+
11
+ static #EXPIRY_OFFSET = 30 * 24 * 60 * 60 * 1000
12
+
13
+ #connection
14
+ #logger
15
+ constructor (connection, logger) {
16
+ assert.ok(connection)
17
+ assert.strictEqual(typeof connection, 'object')
18
+ assert.ok(logger)
19
+ assert.strictEqual(typeof logger, 'object')
20
+ this.#connection = connection
21
+ this.#logger = logger
22
+ }
23
+
24
+ async get (origin) {
25
+ assert.ok(origin)
26
+ assert.strictEqual(typeof origin, 'string')
27
+ const [rows] = await this.#connection.query(
28
+ 'SELECT policy, expiry FROM signature_policy WHERE origin = ?',
29
+ { replacements: [origin] }
30
+ )
31
+ if (rows.length === 0) {
32
+ return null
33
+ }
34
+ const { policy, expiry } = rows[0]
35
+ return ((new Date(expiry)) > (new Date()))
36
+ ? policy
37
+ : null
38
+ }
39
+
40
+ async set (origin, policy) {
41
+ assert.ok(origin)
42
+ assert.strictEqual(typeof origin, 'string')
43
+ assert.ok(policy)
44
+ assert.strictEqual(typeof policy, 'string')
45
+ assert.ok(SignaturePolicyStorage.#policies.includes(policy))
46
+
47
+ const expiry = new Date(
48
+ Date.now() + SignaturePolicyStorage.#EXPIRY_OFFSET
49
+ )
50
+
51
+ await this.#connection.query(
52
+ `INSERT INTO signature_policy (origin, policy, expiry)
53
+ VALUES (?, ?, ?)
54
+ ON CONFLICT (origin) DO UPDATE
55
+ SET policy = EXCLUDED.policy,
56
+ expiry = EXCLUDED.expiry,
57
+ updated_at = CURRENT_TIMESTAMP`,
58
+ { replacements: [origin, policy, expiry] }
59
+ )
60
+ }
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.38.4",
3
+ "version": "0.39.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,6 @@
32
32
  "@isaacs/ttlcache": "^2.1.4",
33
33
  "activitystrea.ms": "^3.3.0",
34
34
  "express": "^5.2.1",
35
- "http-errors": "^2.0.0",
36
35
  "humanhash": "^1.0.4",
37
36
  "lru-cache": "^11.1.0",
38
37
  "nanoid": "^5.1.5",
package/web/profile.html CHANGED
@@ -45,14 +45,18 @@
45
45
  const u = new URL(url)
46
46
  if (u.origin == page.origin) {
47
47
  return await fetch(u, options)
48
- } else {
48
+ }
49
+
50
+ try {
51
+ return await fetch(u, options)
52
+ } catch (err) {
49
53
  return await fetch('/shared/proxy', {
50
- method: "POST",
51
- headers: {
52
- "Content-Type": "application/x-www-form-urlencoded"
53
- },
54
- body: new URLSearchParams({ id: url }),
55
- });
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/x-www-form-urlencoded'
57
+ },
58
+ body: new URLSearchParams({ id: url })
59
+ })
56
60
  }
57
61
  }
58
62
 
@@ -69,4 +73,4 @@
69
73
  </script>
70
74
  </body>
71
75
 
72
- </html>
76
+ </html>