@evanp/activitypub-bot 0.39.5 → 0.40.1
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/bin/activitypub-bot.js +15 -1
- package/lib/activitypubclient.js +64 -15
- package/lib/app.js +81 -4
- package/lib/httpmessagesignature.js +32 -4
- package/lib/migrations/009-timestamp-to-timestampz.js +50 -0
- package/lib/{ratelimiter.js → requestthrottler.js} +2 -2
- package/lib/routes/webfinger.js +4 -0
- package/lib/safeagent.js +33 -0
- package/package.json +6 -1
package/bin/activitypub-bot.js
CHANGED
|
@@ -17,6 +17,7 @@ const { values } = parseArgs({
|
|
|
17
17
|
fanout: { type: 'string' },
|
|
18
18
|
intake: { type: 'string' },
|
|
19
19
|
'index-file': { type: 'string' },
|
|
20
|
+
'allow-private': { type: 'boolean' },
|
|
20
21
|
help: { type: 'boolean', short: 'h' }
|
|
21
22
|
},
|
|
22
23
|
allowPositionals: false
|
|
@@ -37,6 +38,7 @@ Options:
|
|
|
37
38
|
--intake <number> Number of background intake workers
|
|
38
39
|
--index-file <path> HTML page to show at root path
|
|
39
40
|
--profile-file <path> HTML page to show for bot profiles
|
|
41
|
+
--allow-private flag to allow private network requests
|
|
40
42
|
-h, --help Show this help
|
|
41
43
|
`)
|
|
42
44
|
process.exit(0)
|
|
@@ -48,6 +50,13 @@ const parseNumber = (value) => {
|
|
|
48
50
|
const parsed = Number.parseInt(value, 10)
|
|
49
51
|
return Number.isNaN(parsed) ? undefined : parsed
|
|
50
52
|
}
|
|
53
|
+
function parseBoolean (value) {
|
|
54
|
+
if (value == null || value === '') return undefined
|
|
55
|
+
const normalized = value.trim().toLowerCase()
|
|
56
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
|
57
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
51
60
|
|
|
52
61
|
const baseDir = dirname(fileURLToPath(import.meta.url))
|
|
53
62
|
const DEFAULT_BOTS_CONFIG_FILE = resolve(baseDir, '..', 'bots', 'index.js')
|
|
@@ -69,6 +78,10 @@ const FANOUT = parseNumber(values.fanout) || parseNumber(process.env.FANOUT) ||
|
|
|
69
78
|
const INTAKE = parseNumber(values.intake) || parseNumber(process.env.INTAKE) || 2
|
|
70
79
|
const INDEX_FILE = values['index-file'] || process.env.INDEX_FILE || DEFAULT_INDEX_FILE
|
|
71
80
|
const PROFILE_FILE = values['profile-file'] || process.env.PROFILE_FILE || DEFAULT_PROFILE_FILE
|
|
81
|
+
const ALLOW_PRIVATE = values['allow-private'] ||
|
|
82
|
+
('ALLOW_PRIVATE' in process.env)
|
|
83
|
+
? parseBoolean(process.env.ALLOW_PRIVATE)
|
|
84
|
+
: false
|
|
72
85
|
|
|
73
86
|
const bots = (await import(BOTS_CONFIG_FILE)).default
|
|
74
87
|
|
|
@@ -82,7 +95,8 @@ const app = await makeApp({
|
|
|
82
95
|
fanoutWorkerCount: FANOUT,
|
|
83
96
|
intakeWorkerCount: INTAKE,
|
|
84
97
|
indexFileName: INDEX_FILE,
|
|
85
|
-
profileFileName: PROFILE_FILE
|
|
98
|
+
profileFileName: PROFILE_FILE,
|
|
99
|
+
allowPrivateNetworkRequests: ALLOW_PRIVATE
|
|
86
100
|
})
|
|
87
101
|
|
|
88
102
|
const server = app.listen(parseInt(PORT), () => {
|
package/lib/activitypubclient.js
CHANGED
|
@@ -2,8 +2,10 @@ import assert from 'node:assert'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import dns from 'node:dns/promises'
|
|
5
6
|
|
|
6
7
|
import fetch from 'node-fetch'
|
|
8
|
+
import ipaddr from 'ipaddr.js'
|
|
7
9
|
|
|
8
10
|
import as2 from './activitystreams.js'
|
|
9
11
|
import { SignaturePolicyStorage } from './signaturepolicystorage.js'
|
|
@@ -48,6 +50,14 @@ export class ActivityPubClientError extends Error {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
export class ProtocolError extends Error {
|
|
54
|
+
constructor (url) {
|
|
55
|
+
super(`URL ${url} uses a disallowed protocol`)
|
|
56
|
+
this.name = 'ProtocolError'
|
|
57
|
+
this.url = url
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export class ActivityPubClient {
|
|
52
62
|
static #githubUrl = 'https://github.com/evanp/activitypub-bot'
|
|
53
63
|
static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
|
|
@@ -61,18 +71,19 @@ export class ActivityPubClient {
|
|
|
61
71
|
#signer = null
|
|
62
72
|
#digester = null
|
|
63
73
|
#logger = null
|
|
64
|
-
#
|
|
74
|
+
#throttler
|
|
65
75
|
#cache
|
|
66
76
|
#messageSigner
|
|
67
77
|
#policyStorage
|
|
78
|
+
#agent
|
|
68
79
|
|
|
69
|
-
constructor (keyStorage, urlFormatter, signer, digester, logger,
|
|
80
|
+
constructor (keyStorage, urlFormatter, signer, digester, logger, throttler, cache, messageSigner, policyStorage, agent = null) {
|
|
70
81
|
assert.strictEqual(typeof keyStorage, 'object')
|
|
71
82
|
assert.strictEqual(typeof urlFormatter, 'object')
|
|
72
83
|
assert.strictEqual(typeof signer, 'object')
|
|
73
84
|
assert.strictEqual(typeof digester, 'object')
|
|
74
85
|
assert.strictEqual(typeof logger, 'object')
|
|
75
|
-
assert.strictEqual(typeof
|
|
86
|
+
assert.strictEqual(typeof throttler, 'object')
|
|
76
87
|
assert.strictEqual(typeof cache, 'object')
|
|
77
88
|
assert.strictEqual(typeof messageSigner, 'object')
|
|
78
89
|
assert.strictEqual(typeof policyStorage, 'object')
|
|
@@ -81,10 +92,15 @@ export class ActivityPubClient {
|
|
|
81
92
|
this.#signer = signer
|
|
82
93
|
this.#digester = digester
|
|
83
94
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
84
|
-
this.#
|
|
95
|
+
this.#throttler = throttler
|
|
85
96
|
this.#cache = cache
|
|
86
97
|
this.#messageSigner = messageSigner
|
|
87
98
|
this.#policyStorage = policyStorage
|
|
99
|
+
this.#agent = agent
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get allowPrivateNetworkRequests () {
|
|
103
|
+
return this.#agent == null
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
async get (url, username = this.#urlFormatter.hostname) {
|
|
@@ -179,10 +195,10 @@ export class ActivityPubClient {
|
|
|
179
195
|
}
|
|
180
196
|
}
|
|
181
197
|
const hostname = parsed.hostname
|
|
182
|
-
this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for
|
|
183
|
-
await this.#
|
|
198
|
+
this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for throttler')
|
|
199
|
+
await this.#throttler.throttle(hostname)
|
|
184
200
|
this.#logger.debug({ url: baseUrl }, 'Fetching with GET')
|
|
185
|
-
let res = await fetch(baseUrl,
|
|
201
|
+
let res = await this.#fetch(baseUrl,
|
|
186
202
|
{
|
|
187
203
|
method,
|
|
188
204
|
headers
|
|
@@ -200,7 +216,7 @@ export class ActivityPubClient {
|
|
|
200
216
|
delete headers['signature-input']
|
|
201
217
|
headers.signature =
|
|
202
218
|
await this.#sign({ username, url: baseUrl, method, headers })
|
|
203
|
-
res = await fetch(baseUrl,
|
|
219
|
+
res = await this.#fetch(baseUrl,
|
|
204
220
|
{
|
|
205
221
|
method,
|
|
206
222
|
headers
|
|
@@ -208,7 +224,7 @@ export class ActivityPubClient {
|
|
|
208
224
|
)
|
|
209
225
|
}
|
|
210
226
|
|
|
211
|
-
await this.#
|
|
227
|
+
await this.#throttler.update(hostname, res.headers)
|
|
212
228
|
this.#logger.debug({ url }, 'Finished GET')
|
|
213
229
|
if (useCache && res.status === 304) {
|
|
214
230
|
this.#logger.debug({ baseUrl }, '304 Not Modified, returning cached object')
|
|
@@ -316,10 +332,10 @@ export class ActivityPubClient {
|
|
|
316
332
|
throw new Error(`Unexpected signature policy ${storedPolicy}`)
|
|
317
333
|
}
|
|
318
334
|
const hostname = parsed.hostname
|
|
319
|
-
this.#logger.debug({ url, hostname }, 'Waiting for
|
|
320
|
-
await this.#
|
|
335
|
+
this.#logger.debug({ url, hostname }, 'Waiting for throttler')
|
|
336
|
+
await this.#throttler.throttle(hostname)
|
|
321
337
|
this.#logger.debug({ url }, 'Fetching POST')
|
|
322
|
-
let res = await fetch(url,
|
|
338
|
+
let res = await this.#fetch(url,
|
|
323
339
|
{
|
|
324
340
|
method,
|
|
325
341
|
headers,
|
|
@@ -339,7 +355,7 @@ export class ActivityPubClient {
|
|
|
339
355
|
}
|
|
340
356
|
headers.signature =
|
|
341
357
|
await this.#sign({ username, url, method, headers })
|
|
342
|
-
res = await fetch(url,
|
|
358
|
+
res = await this.#fetch(url,
|
|
343
359
|
{
|
|
344
360
|
method,
|
|
345
361
|
headers,
|
|
@@ -347,8 +363,8 @@ export class ActivityPubClient {
|
|
|
347
363
|
}
|
|
348
364
|
)
|
|
349
365
|
}
|
|
350
|
-
this.#logger.debug({ hostname }, 'updating
|
|
351
|
-
await this.#
|
|
366
|
+
this.#logger.debug({ hostname }, 'updating throttler')
|
|
367
|
+
await this.#throttler.update(hostname, res.headers)
|
|
352
368
|
this.#logger.debug({ url }, 'Done fetching POST')
|
|
353
369
|
if (res.status < 200 || res.status > 299) {
|
|
354
370
|
const body = await res.text()
|
|
@@ -503,4 +519,37 @@ export class ActivityPubClient {
|
|
|
503
519
|
}
|
|
504
520
|
return results
|
|
505
521
|
}
|
|
522
|
+
|
|
523
|
+
async #fetch (url, options) {
|
|
524
|
+
if (!(await this.#checkProtocol(url))) {
|
|
525
|
+
throw new ProtocolError(url)
|
|
526
|
+
}
|
|
527
|
+
const fullOptions = {
|
|
528
|
+
...options,
|
|
529
|
+
agent: this.#agent ?? undefined,
|
|
530
|
+
timeout: 10000,
|
|
531
|
+
size: 1024 * 1024,
|
|
532
|
+
follow: 10
|
|
533
|
+
}
|
|
534
|
+
return await fetch(url, fullOptions)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async #checkProtocol (url) {
|
|
538
|
+
const parsed = (new URL(url))
|
|
539
|
+
switch (parsed.protocol) {
|
|
540
|
+
case 'https:':
|
|
541
|
+
return true
|
|
542
|
+
case 'http:': {
|
|
543
|
+
if (this.#agent) {
|
|
544
|
+
return false
|
|
545
|
+
}
|
|
546
|
+
const { address } = await dns.lookup(parsed.hostname)
|
|
547
|
+
const addr = ipaddr.parse(address)
|
|
548
|
+
const range = addr.range()
|
|
549
|
+
return range !== 'unicast'
|
|
550
|
+
}
|
|
551
|
+
default:
|
|
552
|
+
return false
|
|
553
|
+
}
|
|
554
|
+
}
|
|
506
555
|
}
|
package/lib/app.js
CHANGED
|
@@ -7,6 +7,8 @@ import { Sequelize } from 'sequelize'
|
|
|
7
7
|
import express from 'express'
|
|
8
8
|
import Logger from 'pino'
|
|
9
9
|
import HTTPLogger from 'pino-http'
|
|
10
|
+
import { RedisStore } from 'rate-limit-redis'
|
|
11
|
+
import { createClient } from 'redis'
|
|
10
12
|
|
|
11
13
|
import { ActivityDistributor } from './activitydistributor.js'
|
|
12
14
|
import { ActivityPubClient, ActivityPubClientError } from './activitypubclient.js'
|
|
@@ -40,13 +42,15 @@ import { JobQueue } from './jobqueue.js'
|
|
|
40
42
|
import { JobReaper } from './jobreaper.js'
|
|
41
43
|
import { DeliveryWorker } from './deliveryworker.js'
|
|
42
44
|
import { DistributionWorker } from './distributionworker.js'
|
|
43
|
-
import {
|
|
45
|
+
import { RequestThrottler } from '../lib/requestthrottler.js'
|
|
44
46
|
import DoNothingBot from './bots/donothing.js'
|
|
45
47
|
import { FanoutWorker } from './fanoutworker.js'
|
|
46
48
|
import { IntakeWorker } from './intakeworker.js'
|
|
47
49
|
import { RemoteObjectCache } from './remoteobjectcache.js'
|
|
48
50
|
import { HTTPMessageSignature } from './httpmessagesignature.js'
|
|
49
51
|
import { SignaturePolicyStorage } from './signaturepolicystorage.js'
|
|
52
|
+
import { SafeAgent } from './safeagent.js'
|
|
53
|
+
import { rateLimit } from 'express-rate-limit'
|
|
50
54
|
|
|
51
55
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
52
56
|
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
@@ -59,7 +63,7 @@ function createWorkers (logger, count, WorkerClass, ...args) {
|
|
|
59
63
|
return { workers, runs }
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
|
|
66
|
+
export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME, allowPrivateNetworkRequests = false, redisUrl }) {
|
|
63
67
|
const logger = Logger({
|
|
64
68
|
level: logLevel
|
|
65
69
|
})
|
|
@@ -76,10 +80,13 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
76
80
|
const botDataStorage = new BotDataStorage(connection)
|
|
77
81
|
const keyStorage = new KeyStorage(connection, logger)
|
|
78
82
|
const objectStorage = new ObjectStorage(connection)
|
|
79
|
-
const
|
|
83
|
+
const throttler = new RequestThrottler(connection, logger)
|
|
80
84
|
const remoteObjectCache = new RemoteObjectCache(connection, logger)
|
|
81
85
|
const policyStore = new SignaturePolicyStorage(connection, logger)
|
|
82
|
-
const
|
|
86
|
+
const agent = (allowPrivateNetworkRequests)
|
|
87
|
+
? null
|
|
88
|
+
: new SafeAgent()
|
|
89
|
+
const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, throttler, remoteObjectCache, messageSigner, policyStore, agent)
|
|
83
90
|
const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
|
|
84
91
|
const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
|
|
85
92
|
const jobQueue = new JobQueue(connection, logger)
|
|
@@ -117,6 +124,13 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
117
124
|
// TODO: Make an endpoint for tagged objects
|
|
118
125
|
const transformer = new Transformer(origin + '/tag/', client)
|
|
119
126
|
|
|
127
|
+
let redisClient
|
|
128
|
+
|
|
129
|
+
if (redisUrl) {
|
|
130
|
+
redisClient = createClient({ url: redisUrl })
|
|
131
|
+
await redisClient.connect()
|
|
132
|
+
}
|
|
133
|
+
|
|
120
134
|
await Promise.all(
|
|
121
135
|
Object.entries(bots).map(([key, bot]) => bot.initialize(
|
|
122
136
|
new BotContext(
|
|
@@ -188,6 +202,65 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
188
202
|
|
|
189
203
|
app.disable('x-powered-by')
|
|
190
204
|
|
|
205
|
+
const rateLimitHandler = (req, res) => {
|
|
206
|
+
res.status(429)
|
|
207
|
+
.set('Content-Type', 'application/problem+json')
|
|
208
|
+
.send({
|
|
209
|
+
type: 'about:blank',
|
|
210
|
+
status: 429,
|
|
211
|
+
title: 'Too Many Requests',
|
|
212
|
+
detail: 'You made too many requests.'
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
app.use(rateLimit({
|
|
217
|
+
identifier: 'steady-get',
|
|
218
|
+
windowMs: 60 * 1000,
|
|
219
|
+
limit: 2400,
|
|
220
|
+
standardHeaders: 'draft-8',
|
|
221
|
+
legacyHeaders: false,
|
|
222
|
+
skip: (req, res) => !['HEAD', 'GET'].includes(req.method),
|
|
223
|
+
handler: rateLimitHandler,
|
|
224
|
+
store: (redisClient)
|
|
225
|
+
? new RedisStore({
|
|
226
|
+
prefix: `${origin}:steady-get:`,
|
|
227
|
+
sendCommand: (...args) => redisClient.sendCommand(args)
|
|
228
|
+
})
|
|
229
|
+
: undefined
|
|
230
|
+
}))
|
|
231
|
+
|
|
232
|
+
app.use(rateLimit({
|
|
233
|
+
identifier: 'burst-get',
|
|
234
|
+
windowMs: 10 * 1000,
|
|
235
|
+
limit: 1000,
|
|
236
|
+
standardHeaders: 'draft-8',
|
|
237
|
+
legacyHeaders: false,
|
|
238
|
+
skip: (req, res) => !['HEAD', 'GET'].includes(req.method),
|
|
239
|
+
handler: rateLimitHandler,
|
|
240
|
+
store: (redisClient)
|
|
241
|
+
? new RedisStore({
|
|
242
|
+
prefix: `${origin}:burst-get:`,
|
|
243
|
+
sendCommand: (...args) => redisClient.sendCommand(args)
|
|
244
|
+
})
|
|
245
|
+
: undefined
|
|
246
|
+
}))
|
|
247
|
+
|
|
248
|
+
app.use(rateLimit({
|
|
249
|
+
identifier: 'post',
|
|
250
|
+
windowMs: 60 * 1000,
|
|
251
|
+
limit: 600,
|
|
252
|
+
standardHeaders: 'draft-8',
|
|
253
|
+
legacyHeaders: false,
|
|
254
|
+
skip: (req, res) => req.method !== 'POST',
|
|
255
|
+
handler: rateLimitHandler,
|
|
256
|
+
store: (redisClient)
|
|
257
|
+
? new RedisStore({
|
|
258
|
+
prefix: `${origin}:post:`,
|
|
259
|
+
sendCommand: (...args) => redisClient.sendCommand(args)
|
|
260
|
+
})
|
|
261
|
+
: undefined
|
|
262
|
+
}))
|
|
263
|
+
|
|
191
264
|
app.use(async (req, res, next) => {
|
|
192
265
|
let id = req.get('x-request-id')
|
|
193
266
|
if (!id || !id.match(UUID_REGEXP)) {
|
|
@@ -328,6 +401,10 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
328
401
|
await Promise.allSettled(distributionWorkerRuns)
|
|
329
402
|
await Promise.allSettled(deliveryWorkerRuns)
|
|
330
403
|
await jobReaperRun
|
|
404
|
+
if (redisClient) {
|
|
405
|
+
logger.info('Closing Redis connection')
|
|
406
|
+
await redisClient.disconnect()
|
|
407
|
+
}
|
|
331
408
|
logger.info('Closing database connection')
|
|
332
409
|
await connection.close()
|
|
333
410
|
logger.info('Done')
|
|
@@ -86,9 +86,12 @@ export class HTTPMessageSignature {
|
|
|
86
86
|
this.#logger.debug(
|
|
87
87
|
{ inputs }, 'validating signature'
|
|
88
88
|
)
|
|
89
|
-
const input = this.#bestInput(inputs)
|
|
89
|
+
const input = this.#bestInput(inputs, url, method)
|
|
90
90
|
if (!input) {
|
|
91
|
-
throw new Error(
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Signatures must have one of these algorithms: [${HTTPMessageSignature.#preferredAlgs.join(',')}], @method, either @target-uri or
|
|
93
|
+
@scheme + @authority + @path + @query (if there is a query), and content-digest for POST`
|
|
94
|
+
)
|
|
92
95
|
}
|
|
93
96
|
this.#logger.debug(
|
|
94
97
|
{ input }, 'best input'
|
|
@@ -133,9 +136,12 @@ export class HTTPMessageSignature {
|
|
|
133
136
|
return signatures
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
#bestInput (inputs) {
|
|
139
|
+
#bestInput (inputs, url, method) {
|
|
137
140
|
for (const alg of HTTPMessageSignature.#preferredAlgs) {
|
|
138
|
-
const entry = Object.values(inputs).find(
|
|
141
|
+
const entry = Object.values(inputs).find(
|
|
142
|
+
input => input.alg === alg &&
|
|
143
|
+
this.#sufficientInput(input, url, method)
|
|
144
|
+
)
|
|
139
145
|
if (entry) {
|
|
140
146
|
return entry
|
|
141
147
|
}
|
|
@@ -143,6 +149,28 @@ export class HTTPMessageSignature {
|
|
|
143
149
|
return null
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
#sufficientInput (input, url, method) {
|
|
153
|
+
assert.ok(input)
|
|
154
|
+
assert.strictEqual(typeof input, 'object')
|
|
155
|
+
assert.ok(Array.isArray(input.params))
|
|
156
|
+
const params = new Set(input.params)
|
|
157
|
+
if (!params.has('@method')) {
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
if (method?.toUpperCase() === 'POST' && !params.has('content-digest')) {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
if (params.has('@target-uri')) {
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
params.has('@scheme') &&
|
|
168
|
+
params.has('@authority') &&
|
|
169
|
+
params.has('@path') &&
|
|
170
|
+
(!url || !URL.parse(url).query || params.has('@query'))
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
146
174
|
#inputData (input, method, url, headers) {
|
|
147
175
|
const signatureParams = []
|
|
148
176
|
const parsed = URL.parse(url)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const id = '009-timestamp-to-timestampz'
|
|
2
|
+
|
|
3
|
+
const COLUMNS = [
|
|
4
|
+
['actorcollection', 'createdat'],
|
|
5
|
+
['actorcollection', 'updatedat'],
|
|
6
|
+
['actorcollectionpage', 'createdat'],
|
|
7
|
+
['objects', 'createdat'],
|
|
8
|
+
['objects', 'updatedat'],
|
|
9
|
+
['collections', 'createdat'],
|
|
10
|
+
['collections', 'updatedat'],
|
|
11
|
+
['pages', 'createdat'],
|
|
12
|
+
['botdata', 'createdat'],
|
|
13
|
+
['botdata', 'updatedat'],
|
|
14
|
+
['new_remotekeys', 'createdat'],
|
|
15
|
+
['new_remotekeys', 'updatedat'],
|
|
16
|
+
['lastactivity', 'createdat'],
|
|
17
|
+
['lastactivity', 'updatedat'],
|
|
18
|
+
['job', 'claimed_at'],
|
|
19
|
+
['job', 'retry_after'],
|
|
20
|
+
['job', 'created_at'],
|
|
21
|
+
['job', 'updated_at'],
|
|
22
|
+
['rate_limit', 'reset'],
|
|
23
|
+
['rate_limit', 'created_at'],
|
|
24
|
+
['rate_limit', 'updated_at'],
|
|
25
|
+
['failed_job', 'claimed_at'],
|
|
26
|
+
['failed_job', 'retry_after'],
|
|
27
|
+
['failed_job', 'created_at'],
|
|
28
|
+
['failed_job', 'updated_at'],
|
|
29
|
+
['remote_object_cache', 'last_modified'],
|
|
30
|
+
['remote_object_cache', 'expiry'],
|
|
31
|
+
['remote_object_cache', 'created_at'],
|
|
32
|
+
['remote_object_cache', 'updated_at'],
|
|
33
|
+
['signature_policy', 'expiry'],
|
|
34
|
+
['signature_policy', 'created_at'],
|
|
35
|
+
['signature_policy', 'updated_at']
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
export async function up (connection, queryOptions = {}) {
|
|
39
|
+
if (connection.getDialect() !== 'postgres') {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
for (const [table, column] of COLUMNS) {
|
|
43
|
+
await connection.query(
|
|
44
|
+
`ALTER TABLE "${table}"
|
|
45
|
+
ALTER COLUMN "${column}" TYPE TIMESTAMPTZ
|
|
46
|
+
USING "${column}" AT TIME ZONE 'UTC'`,
|
|
47
|
+
queryOptions
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert'
|
|
|
3
3
|
|
|
4
4
|
const BETA = 0.75
|
|
5
5
|
|
|
6
|
-
export class
|
|
6
|
+
export class RequestThrottler {
|
|
7
7
|
#connection
|
|
8
8
|
#logger
|
|
9
9
|
|
|
@@ -14,7 +14,7 @@ export class RateLimiter {
|
|
|
14
14
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
async
|
|
17
|
+
async throttle (host, maxWaitTime = 30000) {
|
|
18
18
|
assert.strictEqual(typeof host, 'string')
|
|
19
19
|
assert.strictEqual(typeof maxWaitTime, 'number')
|
|
20
20
|
const waitTime = await this.#getWaitTime(host, maxWaitTime)
|
package/lib/routes/webfinger.js
CHANGED
|
@@ -86,6 +86,10 @@ router.get('/.well-known/webfinger', async (req, res, next) => {
|
|
|
86
86
|
if (!resource) {
|
|
87
87
|
return next(new ProblemDetailsError(400, 'resource parameter is required'))
|
|
88
88
|
}
|
|
89
|
+
if (Array.isArray(resource)) {
|
|
90
|
+
assert.ok(resource.length > 1)
|
|
91
|
+
throw new ProblemDetailsError(400, '"resource" parameter required exactly once')
|
|
92
|
+
}
|
|
89
93
|
const colon = resource.indexOf(':')
|
|
90
94
|
const protocol = resource.slice(0, colon)
|
|
91
95
|
|
package/lib/safeagent.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import https from 'node:https'
|
|
2
|
+
import dns from 'node:dns'
|
|
3
|
+
|
|
4
|
+
import ipaddr from 'ipaddr.js'
|
|
5
|
+
|
|
6
|
+
function isPrivateIP (address) {
|
|
7
|
+
if (!ipaddr.isValid(address)) return false
|
|
8
|
+
const addr = ipaddr.parse(address)
|
|
9
|
+
const range = addr.range()
|
|
10
|
+
return range !== 'unicast'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class PrivateNetworkError extends Error {
|
|
14
|
+
constructor (address) {
|
|
15
|
+
super(`Private network address ${address}`)
|
|
16
|
+
this.name = 'PrivateNetworkError'
|
|
17
|
+
this.address = address
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SafeAgent extends https.Agent {
|
|
22
|
+
createConnection (options, callback) {
|
|
23
|
+
dns.lookup(options.hostname, (err, address) => {
|
|
24
|
+
if (err) {
|
|
25
|
+
return callback(err)
|
|
26
|
+
}
|
|
27
|
+
if (isPrivateIP(address)) {
|
|
28
|
+
return callback(new PrivateNetworkError(address))
|
|
29
|
+
}
|
|
30
|
+
super.createConnection({ ...options, hostname: address }, callback)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evanp/activitypub-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
4
4
|
"description": "server-side ActivityPub bot framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "NODE_ENV=test node --test",
|
|
12
|
+
"test:serial": "NODE_ENV=test node --test --test-concurrency=1",
|
|
12
13
|
"start": "npx activitypub-bot",
|
|
13
14
|
"lint": "eslint ."
|
|
14
15
|
},
|
|
@@ -32,12 +33,16 @@
|
|
|
32
33
|
"@isaacs/ttlcache": "^2.1.4",
|
|
33
34
|
"activitystrea.ms": "^3.3.0",
|
|
34
35
|
"express": "^5.2.1",
|
|
36
|
+
"express-rate-limit": "^8.3.2",
|
|
35
37
|
"humanhash": "^1.0.4",
|
|
38
|
+
"ipaddr.js": "^2.3.0",
|
|
36
39
|
"lru-cache": "^11.1.0",
|
|
37
40
|
"nanoid": "^5.1.5",
|
|
38
41
|
"node-fetch": "^3.3.2",
|
|
39
42
|
"pino": "^10.1.1",
|
|
40
43
|
"pino-http": "^11.0.0",
|
|
44
|
+
"rate-limit-redis": "^4.3.1",
|
|
45
|
+
"redis": "^5.11.0",
|
|
41
46
|
"sequelize": "^6.37.7"
|
|
42
47
|
},
|
|
43
48
|
"devDependencies": {
|