@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.
- package/lib/activitypubclient.js +88 -12
- package/lib/app.js +12 -3
- package/lib/errors.js +158 -0
- package/lib/httpsignature.js +11 -11
- package/lib/httpsignatureauthenticator.js +15 -15
- package/lib/migrations/008-signature-policy.js +16 -0
- package/lib/routes/collection.js +7 -7
- package/lib/routes/health.js +2 -2
- package/lib/routes/inbox.js +21 -14
- package/lib/routes/object.js +13 -13
- package/lib/routes/profile.js +2 -2
- package/lib/routes/proxy.js +5 -5
- package/lib/routes/sharedinbox.js +13 -9
- package/lib/routes/user.js +9 -9
- package/lib/routes/webfinger.js +9 -9
- package/lib/signaturepolicystorage.js +61 -0
- package/package.json +1 -2
- package/web/profile.html +12 -8
package/lib/activitypubclient.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
222
|
-
const
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/httpsignature.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
|
|
4
|
-
import
|
|
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
|
|
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
|
|
23
|
+
throw new ProblemDetailsError(401, 'No signature provided')
|
|
24
24
|
}
|
|
25
25
|
if (!method) {
|
|
26
|
-
throw
|
|
26
|
+
throw new ProblemDetailsError(400, 'No HTTP method provided')
|
|
27
27
|
}
|
|
28
28
|
if (!path) {
|
|
29
|
-
throw
|
|
29
|
+
throw new ProblemDetailsError(400, 'No URL path provided')
|
|
30
30
|
}
|
|
31
31
|
if (!headers) {
|
|
32
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
48
|
+
throw new ProblemDetailsError(401, 'Only rsa-sha256 or hs2019 with RSA supported')
|
|
49
49
|
}
|
|
50
50
|
if (!params.headers) {
|
|
51
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
155
|
+
throw new ProblemDetailsError(400, 'No created timestamp provided')
|
|
156
156
|
}
|
|
157
157
|
if (Math.abs(Date.now() - created * 1000) > HTTPSignatureAuthenticator.#maxDateDiff) {
|
|
158
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/lib/routes/collection.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
66
|
+
return next(new ProblemDetailsError(404, `User ${username} not found`))
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
if (collection === 'inbox') {
|
|
70
|
-
return next(
|
|
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(
|
|
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(
|
|
116
|
+
return next(new ProblemDetailsError(500, 'Error loading collection page'))
|
|
117
117
|
}
|
|
118
118
|
res.status(200)
|
|
119
119
|
res.type(as2.mediaType)
|
package/lib/routes/health.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
|
-
import
|
|
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(
|
|
20
|
+
return next(new ProblemDetailsError(503, 'Service Unavailable'))
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
23
|
|
package/lib/routes/inbox.js
CHANGED
|
@@ -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(
|
|
26
|
+
return next(new ProblemDetailsError(404, `User ${username} not found`))
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
if (!subject) {
|
|
23
|
-
return next(
|
|
30
|
+
return next(new ProblemDetailsError(401, 'Unauthorized'))
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
if (!req.body) {
|
|
27
|
-
return next(
|
|
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(
|
|
44
|
+
return next(new ProblemDetailsError(400, 'Invalid request body'))
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
if (!activity.isActivity()) {
|
|
41
|
-
return next(
|
|
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(
|
|
54
|
+
return next(new ProblemDetailsError(400, 'No actor found in activity'))
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
if (!actor.id) {
|
|
51
|
-
return next(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
98
|
+
return next(new PrincipalNotAuthorizedError('No access to inbox collection', { resource: req.url }))
|
|
92
99
|
})
|
|
93
100
|
|
|
94
101
|
export default router
|
package/lib/routes/object.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
60
|
+
return next(new ProblemDetailsError(400, `Invalid page ${n}`))
|
|
61
61
|
}
|
|
62
62
|
if (!nanoid.match(/^[A-Za-z0-9_\\-]{21}$/)) {
|
|
63
|
-
return next(
|
|
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(
|
|
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(
|
|
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(
|
|
76
|
+
return next(new ProblemDetailsError(403, 'Forbidden'))
|
|
77
77
|
}
|
|
78
78
|
const collectionPage = await objectStorage.getCollectionPage(
|
|
79
79
|
id,
|
package/lib/routes/profile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
|
-
import
|
|
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(
|
|
12
|
+
return next(new ProblemDetailsError(404, `User ${username} not found`))
|
|
13
13
|
}
|
|
14
14
|
res.type('html')
|
|
15
15
|
res.sendFile(profileFileName)
|
package/lib/routes/proxy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
2
|
|
|
3
3
|
import as2 from '../activitystreams.js'
|
|
4
|
-
import
|
|
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(
|
|
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(
|
|
28
|
+
return next(new ProblemDetailsError(400, 'id must be an URL'))
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
if (url.protocol !== 'https:') {
|
|
32
|
-
return next(
|
|
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(
|
|
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(
|
|
35
|
+
return next(new ProblemDetailsError(401, 'Unauthorized'))
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
if (!req.body) {
|
|
35
|
-
return next(
|
|
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(
|
|
49
|
+
return next(new ProblemDetailsError(400, 'Invalid request body'))
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
if (!activity.isActivity()) {
|
|
49
|
-
return next(
|
|
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(
|
|
59
|
+
return next(new ProblemDetailsError(400, 'No actor found in activity'))
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
if (!actor.id) {
|
|
59
|
-
return next(
|
|
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(
|
|
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(
|
|
71
|
+
return next(new ProblemDetailsError(400, 'No activity id found in activity'))
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
logger.info(
|
package/lib/routes/user.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
103
|
+
return next(new ProblemDetailsError(404, `User ${username} not found`))
|
|
104
104
|
}
|
|
105
105
|
if (!bot.icon) {
|
|
106
|
-
return next(
|
|
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(
|
|
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(
|
|
125
|
+
return next(new ProblemDetailsError(404, `User ${username} not found`))
|
|
126
126
|
}
|
|
127
127
|
if (!bot.image) {
|
|
128
|
-
return next(
|
|
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(
|
|
132
|
+
return next(new ProblemDetailsError(500, 'Incorrect image format from bot'))
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
if (bot.image.protocol === 'file:') {
|
package/lib/routes/webfinger.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
|
|
3
3
|
import { Router } from 'express'
|
|
4
|
-
import
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fetch(u, options)
|
|
52
|
+
} catch (err) {
|
|
49
53
|
return await fetch('/shared/proxy', {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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>
|