@evanp/activitypub-bot 0.45.19 → 0.46.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/CHANGELOG.md CHANGED
@@ -9,6 +9,32 @@ and this project adheres to
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.46.0] - 2026-05-25
13
+
14
+ ### Added
15
+
16
+ - Support for nodeinfo 2.1 and 2.2.
17
+ - ActorStorage method for checking active users.
18
+ - nodeinfo properties for monthly active and half-yearly active users.
19
+ - SafeFetcher service class for all outbound HTTP requests
20
+
21
+ ### Changed
22
+
23
+ - Name of package in nodeinfo from `activitypub-bot` to `activitypub-dot-bot` to
24
+ avoid a naming conflict with another package.
25
+ - Use URLFormatter in Transformer and BotContext to short-circuit Webfinger self-request
26
+ - Use SafeFetcher in ActivityPubClient
27
+ - Use SafeFetcher in Transformer
28
+ - Use SafeFetcher in BotContext
29
+ - Encode HTML entities in Transformer.transform()
30
+
31
+ ## [0.45.20] - 2026-05-14
32
+
33
+ ### Fixed
34
+
35
+ - Handle some pathological formats for request throttle resets -- epoch in
36
+ ms and offset in ms.
37
+
12
38
  ## [0.45.19] - 2026-05-14
13
39
 
14
40
  ### Changed
package/README.md CHANGED
@@ -248,6 +248,12 @@ Alternatively, you can implement a whole class of bots with the [BotFactory](#bo
248
248
 
249
249
  Bots will receive a [BotContext](#botcontext) object at initialization. The BotContext is the main way to access data or execute activities.
250
250
 
251
+ It's important to remember that the bot may run on many different physical
252
+ machines in a multi-server deployment. Don't keep volatile state for the bot in
253
+ memory; it will get out of sync between servers, and will be lost between server
254
+ restarts. Instead, use the `getData()`/`setData()` (see below) interfaces to
255
+ save named data items into the database.
256
+
251
257
  ### Bot
252
258
 
253
259
  The Bot interface has the following methods.
@@ -2,10 +2,6 @@ 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'
6
-
7
- import fetch from 'node-fetch'
8
- import ipaddr from 'ipaddr.js'
9
5
 
10
6
  import as2 from './activitystreams.js'
11
7
  import { SignaturePolicyStorage } from './signaturepolicystorage.js'
@@ -52,14 +48,6 @@ export class ActivityPubClientError extends Error {
52
48
  }
53
49
  }
54
50
 
55
- export class ProtocolError extends Error {
56
- constructor (url) {
57
- super(`URL ${url} uses a disallowed protocol`)
58
- this.name = 'ProtocolError'
59
- this.url = url
60
- }
61
- }
62
-
63
51
  export class ActivityPubClient {
64
52
  static #githubUrl = 'https://github.com/evanp/activitypub-bot'
65
53
  static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
@@ -77,9 +65,9 @@ export class ActivityPubClient {
77
65
  #cache
78
66
  #messageSigner
79
67
  #policyStorage
80
- #agent
68
+ #fetcher
81
69
 
82
- constructor (keyStorage, urlFormatter, signer, digester, logger, throttler, cache, messageSigner, policyStorage, agent = null) {
70
+ constructor (keyStorage, urlFormatter, signer, digester, logger, throttler, cache, messageSigner, policyStorage, fetcher) {
83
71
  assert.strictEqual(typeof keyStorage, 'object')
84
72
  assert.strictEqual(typeof urlFormatter, 'object')
85
73
  assert.strictEqual(typeof signer, 'object')
@@ -89,6 +77,7 @@ export class ActivityPubClient {
89
77
  assert.strictEqual(typeof cache, 'object')
90
78
  assert.strictEqual(typeof messageSigner, 'object')
91
79
  assert.strictEqual(typeof policyStorage, 'object')
80
+ assert.strictEqual(typeof fetcher, 'object')
92
81
  this.#keyStorage = keyStorage
93
82
  this.#urlFormatter = urlFormatter
94
83
  this.#signer = signer
@@ -98,11 +87,11 @@ export class ActivityPubClient {
98
87
  this.#cache = cache
99
88
  this.#messageSigner = messageSigner
100
89
  this.#policyStorage = policyStorage
101
- this.#agent = agent
90
+ this.#fetcher = fetcher
102
91
  }
103
92
 
104
93
  get allowPrivateNetworkRequests () {
105
- return this.#agent == null
94
+ return this.#fetcher.allowPrivateNetworkRequests
106
95
  }
107
96
 
108
97
  async get (url, username = this.#urlFormatter.hostname) {
@@ -200,7 +189,7 @@ export class ActivityPubClient {
200
189
  this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for throttler')
201
190
  await this.#throttler.throttle(hostname)
202
191
  this.#logger.debug({ url: baseUrl }, 'Fetching with GET')
203
- let res = await this.#fetch(baseUrl,
192
+ let res = await this.#fetcher.fetch(baseUrl,
204
193
  {
205
194
  method,
206
195
  headers
@@ -218,7 +207,7 @@ export class ActivityPubClient {
218
207
  delete headers['signature-input']
219
208
  headers.signature =
220
209
  await this.#sign({ username, url: baseUrl, method, headers })
221
- res = await this.#fetch(baseUrl,
210
+ res = await this.#fetcher.fetch(baseUrl,
222
211
  {
223
212
  method,
224
213
  headers
@@ -342,7 +331,7 @@ export class ActivityPubClient {
342
331
  this.#logger.debug({ url, hostname }, 'Waiting for throttler')
343
332
  await this.#throttler.throttle(hostname)
344
333
  this.#logger.debug({ url }, 'Fetching POST')
345
- let res = await this.#fetch(url,
334
+ let res = await this.#fetcher.fetch(url,
346
335
  {
347
336
  method,
348
337
  headers,
@@ -363,7 +352,7 @@ export class ActivityPubClient {
363
352
  }
364
353
  headers.signature =
365
354
  await this.#sign({ username, url, method, headers })
366
- res = await this.#fetch(url,
355
+ res = await this.#fetcher.fetch(url,
367
356
  {
368
357
  method,
369
358
  headers,
@@ -530,37 +519,4 @@ export class ActivityPubClient {
530
519
  }
531
520
  return results
532
521
  }
533
-
534
- async #fetch (url, options) {
535
- if (!(await this.#checkProtocol(url))) {
536
- throw new ProtocolError(url)
537
- }
538
- const fullOptions = {
539
- ...options,
540
- agent: this.#agent ?? undefined,
541
- signal: options?.signal ?? AbortSignal.timeout(10000),
542
- size: 1024 * 1024,
543
- follow: 10
544
- }
545
- return await fetch(url, fullOptions)
546
- }
547
-
548
- async #checkProtocol (url) {
549
- const parsed = (new URL(url))
550
- switch (parsed.protocol) {
551
- case 'https:':
552
- return true
553
- case 'http:': {
554
- if (this.#agent) {
555
- return false
556
- }
557
- const { address } = await dns.lookup(parsed.hostname)
558
- const addr = ipaddr.parse(address)
559
- const range = addr.range()
560
- return range !== 'unicast'
561
- }
562
- default:
563
- return false
564
- }
565
- }
566
522
  }
@@ -3,6 +3,8 @@ import assert from 'node:assert'
3
3
 
4
4
  const AS2_NS = 'https://www.w3.org/ns/activitystreams#'
5
5
 
6
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
7
+
6
8
  export class ActorStorage {
7
9
  #connection = null
8
10
  #formatter = null
@@ -330,6 +332,24 @@ export class ActorStorage {
330
332
  )
331
333
  }
332
334
 
335
+ async activeUsers (days) {
336
+ const [result] = await this.#connection.query(
337
+ `SELECT COUNT(DISTINCT username) as active_users
338
+ FROM actorcollectionpage
339
+ WHERE property = 'outbox'
340
+ AND createdat > ?;`,
341
+ { replacements: [new Date(Date.now() - days * MS_PER_DAY)] }
342
+ )
343
+ if (result.length > 0) {
344
+ const row = result[0]
345
+ return (typeof row.active_users === 'string')
346
+ ? parseInt(row.active_users, 10)
347
+ : row.active_users
348
+ } else {
349
+ return 0
350
+ }
351
+ }
352
+
333
353
  async #getCollectionInfo (username, property) {
334
354
  const [result] = await this.#connection.query(
335
355
  `SELECT
package/lib/app.js CHANGED
@@ -51,7 +51,7 @@ import { IntakeWorker } from './intakeworker.js'
51
51
  import { RemoteObjectCache } from './remoteobjectcache.js'
52
52
  import { HTTPMessageSignature } from './httpmessagesignature.js'
53
53
  import { SignaturePolicyStorage } from './signaturepolicystorage.js'
54
- import { SafeAgent } from './safeagent.js'
54
+ import { SafeFetcher } from './safefetcher.js'
55
55
  import { EndpointCache } from './endpointcache.js'
56
56
 
57
57
  const currentDir = dirname(fileURLToPath(import.meta.url))
@@ -98,10 +98,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
98
98
  const throttler = new RequestThrottler(connection, logger)
99
99
  const remoteObjectCache = new RemoteObjectCache(connection, logger)
100
100
  const policyStore = new SignaturePolicyStorage(connection, logger)
101
- const agent = (allowPrivateNetworkRequests)
102
- ? null
103
- : new SafeAgent()
104
- const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, throttler, remoteObjectCache, messageSigner, policyStore, agent)
101
+ const fetcher = new SafeFetcher({ allowPrivate: allowPrivateNetworkRequests })
102
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, throttler, remoteObjectCache, messageSigner, policyStore, fetcher)
105
103
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
106
104
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, messageSigner, digester, logger)
107
105
  const jobQueue = new JobQueue(connection, logger)
@@ -134,7 +132,9 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
134
132
  )
135
133
 
136
134
  // TODO: Make an endpoint for tagged objects
137
- const transformer = new Transformer(origin + '/tag/', client)
135
+ const transformer = new Transformer(
136
+ origin + '/tag/', client, fetcher, formatter
137
+ )
138
138
 
139
139
  let redisClient
140
140
 
@@ -157,7 +157,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
157
157
  formatter,
158
158
  transformer,
159
159
  logger,
160
- bots
160
+ bots,
161
+ fetcher
161
162
  )
162
163
  ))
163
164
  )
@@ -179,7 +180,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
179
180
  formatter,
180
181
  transformer,
181
182
  logger,
182
- bots
183
+ bots,
184
+ fetcher
183
185
  ))
184
186
 
185
187
  memoryStatus('bots initialized')
package/lib/botcontext.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
 
3
3
  import { nanoid } from 'nanoid'
4
- import fetch from 'node-fetch'
5
4
 
6
5
  import as2 from './activitystreams.js'
7
6
 
@@ -28,6 +27,7 @@ export class BotContext {
28
27
  #transformer = null
29
28
  #logger = null
30
29
  #bots
30
+ #fetcher
31
31
 
32
32
  get botId () {
33
33
  return this.#botId
@@ -47,8 +47,20 @@ export class BotContext {
47
47
  formatter,
48
48
  transformer,
49
49
  logger,
50
- bots
50
+ bots,
51
+ fetcher
51
52
  ) {
53
+ assert.strictEqual(typeof botId, 'string')
54
+ assert.strictEqual(typeof botDataStorage, 'object')
55
+ assert.strictEqual(typeof objectStorage, 'object')
56
+ assert.strictEqual(typeof actorStorage, 'object')
57
+ assert.strictEqual(typeof client, 'object')
58
+ assert.strictEqual(typeof distributor, 'object')
59
+ assert.strictEqual(typeof formatter, 'object')
60
+ assert.strictEqual(typeof transformer, 'object')
61
+ assert.strictEqual(typeof logger, 'object')
62
+ assert.strictEqual(typeof bots, 'object')
63
+ assert.strictEqual(typeof fetcher, 'object')
52
64
  this.#botId = botId
53
65
  this.#botDataStorage = botDataStorage
54
66
  this.#objectStorage = objectStorage
@@ -59,6 +71,7 @@ export class BotContext {
59
71
  this.#transformer = transformer
60
72
  this.#bots = bots
61
73
  this.#logger = logger.child({ class: 'BotContext', botId })
74
+ this.#fetcher = fetcher
62
75
  }
63
76
 
64
77
  // copy constructor
@@ -73,7 +86,9 @@ export class BotContext {
73
86
  this.#distributor,
74
87
  this.#formatter,
75
88
  this.#transformer,
76
- this.#logger
89
+ this.#logger,
90
+ this.#bots,
91
+ this.#fetcher
77
92
  )
78
93
  }
79
94
 
@@ -377,10 +392,13 @@ export class BotContext {
377
392
  }
378
393
 
379
394
  async toActorId (webfinger) {
380
- const [, domain] = webfinger.split('@')
395
+ const [username, domain] = webfinger.split('@')
396
+ if (domain.toLowerCase() === this.#formatter.hostname) {
397
+ return this.#formatter.format({ username })
398
+ }
381
399
  const url = `https://${domain}/.well-known/webfinger` +
382
400
  `?resource=acct:${webfinger}`
383
- const res = await fetch(url)
401
+ const res = await this.#fetcher.fetch(url)
384
402
  if (res.status !== 200) {
385
403
  throw new Error(`Status ${res.status} fetching ${url}`)
386
404
  }
@@ -1,20 +1,37 @@
1
1
  import assert from 'node:assert'
2
- import fetch from 'node-fetch'
3
2
 
4
3
  const AS2 = 'https://www.w3.org/ns/activitystreams#'
5
4
 
6
5
  export class Transformer {
7
6
  #tagNamespace = null
8
7
  #client = null
9
- constructor (tagNamespace, client) {
8
+ #fetcher = null
9
+ #formatter = null
10
+
11
+ constructor (tagNamespace, client, fetcher, formatter) {
12
+ assert.strictEqual(typeof tagNamespace, 'string')
13
+ assert.strictEqual(typeof client, 'object')
14
+ assert.strictEqual(typeof fetcher, 'object')
15
+ assert.strictEqual(typeof formatter, 'object')
16
+
10
17
  this.#tagNamespace = tagNamespace
11
18
  this.#client = client
19
+ this.#fetcher = fetcher
20
+ this.#formatter = formatter
21
+
12
22
  assert.ok(this.#tagNamespace)
13
23
  assert.ok(this.#client)
24
+ assert.ok(this.#fetcher)
25
+ assert.ok(this.#formatter)
14
26
  }
15
27
 
16
28
  async transform (text) {
17
29
  let html = text
30
+ .replace(/&/g, '&')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&apos;')
18
35
  let tag = [];
19
36
  ({ html, tag } = this.#replaceUrls(html, tag));
20
37
  ({ html, tag } = this.#replaceHashtags(html, tag));
@@ -71,11 +88,14 @@ export class Transformer {
71
88
  async #homePage (webfinger) {
72
89
  if (!this.#client) return null
73
90
  const [username, domain] = webfinger.split('@')
91
+ if (domain.toLowerCase() === this.#formatter.hostname) {
92
+ return this.#formatter.format({ username, type: 'profile' })
93
+ }
74
94
  const url = `https://${domain}/.well-known/webfinger?` +
75
95
  `resource=acct:${username}@${domain}`
76
96
  let json = null
77
97
  try {
78
- const response = await fetch(url,
98
+ const response = await this.#fetcher.fetch(url,
79
99
  { headers: { Accept: 'application/jrd+json' } })
80
100
  if (response.status !== 200) return null
81
101
  json = await response.json()
@@ -0,0 +1,9 @@
1
+ export const id = '012-actor-outbox-created-at-index'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ CREATE INDEX actorcollectionpage_outbox_createdat
6
+ ON actorcollectionpage (createdat)
7
+ WHERE property = 'outbox';
8
+ `, queryOptions)
9
+ }
@@ -2,7 +2,8 @@ import { setTimeout as sleep } from 'node:timers/promises'
2
2
  import assert from 'node:assert'
3
3
 
4
4
  const BETA = 0.75
5
- const EPOCH_THRESHOLD = 30 * 24 * 60 * 60
5
+ const OFFSET_THRESHOLD = 30 * 24 * 60 * 60
6
+ const DEFAULT_RESET = 30 * 1000
6
7
 
7
8
  export class ThrottleError extends Error {
8
9
  constructor (message, waitTime) {
@@ -43,25 +44,33 @@ export class RequestThrottler {
43
44
  assert.strictEqual(typeof host, 'string')
44
45
  assert.strictEqual(typeof headers, 'object')
45
46
 
47
+ const retryAfterHeader = headers.get('retry-after')
46
48
  const resetHeader = headers.get('x-ratelimit-reset')
47
49
  const remainingHeader = headers.get('x-ratelimit-remaining')
48
50
 
49
- if (resetHeader && remainingHeader) {
51
+ if (retryAfterHeader) {
52
+ const remaining = 0
53
+ const reset = this.#headerToReset(retryAfterHeader)
54
+ this.#logger.debug(
55
+ { reset, remaining, host, retryAfterHeader },
56
+ 'updating'
57
+ )
58
+ await this.#connection.query(
59
+ `INSERT INTO rate_limit (host, remaining, reset)
60
+ VALUES (?, ?, ?)
61
+ ON CONFLICT (host) DO UPDATE
62
+ SET remaining = EXCLUDED.remaining,
63
+ reset = EXCLUDED.reset,
64
+ updated_at = CURRENT_TIMESTAMP`,
65
+ { replacements: [host, remaining, reset] }
66
+ )
67
+ } else if (resetHeader && remainingHeader) {
50
68
  const remaining = parseInt(remainingHeader)
51
- let resetSeconds
52
- let reset
53
- if (resetHeader.match(/^\d+$/)) {
54
- resetSeconds = parseInt(resetHeader)
55
- if (resetSeconds < EPOCH_THRESHOLD) {
56
- reset = new Date(Date.now() + (resetSeconds * 1000))
57
- } else {
58
- reset = new Date(resetSeconds * 1000)
59
- }
60
- } else {
61
- reset = new Date(resetHeader)
62
- resetSeconds = reset - Date.now()
63
- }
64
- this.#logger.debug({ reset, remaining, host }, 'updating')
69
+ const reset = this.#headerToReset(resetHeader)
70
+ this.#logger.debug(
71
+ { reset, remaining, host, resetHeader, remainingHeader },
72
+ 'updating'
73
+ )
65
74
  await this.#connection.query(
66
75
  `INSERT INTO rate_limit (host, remaining, reset)
67
76
  VALUES (?, ?, ?)
@@ -165,4 +174,39 @@ export class RequestThrottler {
165
174
  { replacements: [host] }
166
175
  )
167
176
  }
177
+
178
+ #headerToReset (header) {
179
+ let reset
180
+ const now = Date.now()
181
+ if (header.match(/^\d+$/)) {
182
+ const num = parseInt(header)
183
+
184
+ if (num > now - (3600 * 1000)) {
185
+ // epoch in ms
186
+ reset = new Date(num)
187
+ } else if (num > (now / 1000) - 3600) {
188
+ // epoch in s
189
+ reset = new Date(num * 1000)
190
+ } else if (num < OFFSET_THRESHOLD) {
191
+ // offset in s
192
+ reset = new Date(now + (num * 1000))
193
+ } else if (num < OFFSET_THRESHOLD * 1000) {
194
+ // offset in ms
195
+ reset = new Date(now + num)
196
+ } else {
197
+ // no good guesses; default
198
+ reset = new Date(now + DEFAULT_RESET)
199
+ }
200
+ } else {
201
+ reset = new Date(header)
202
+ if (Number.isNaN(reset.getTime())) {
203
+ this.#logger.warn(
204
+ { header },
205
+ 'Error parsing header for request throttling'
206
+ )
207
+ reset = new Date(now + DEFAULT_RESET)
208
+ }
209
+ }
210
+ return reset
211
+ }
168
212
  }
@@ -9,7 +9,7 @@ import { ProblemDetailsError } from '../errors.js'
9
9
  const __filename = fileURLToPath(import.meta.url)
10
10
  const __dirname = path.dirname(__filename)
11
11
 
12
- const packageName = 'activitypub-bot'
12
+ const packageName = 'activitypub-dot-bot'
13
13
 
14
14
  const { version: packageVersion } = JSON.parse(
15
15
  fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')
@@ -17,7 +17,7 @@ const { version: packageVersion } = JSON.parse(
17
17
 
18
18
  const router = Router()
19
19
 
20
- const VERSIONS = ['2.0']
20
+ const VERSIONS = ['2.0', '2.1', '2.2']
21
21
 
22
22
  router.get('/.well-known/nodeinfo', async (req, res, next) => {
23
23
  const { origin } = req.app.locals
@@ -33,7 +33,7 @@ router.get('/.well-known/nodeinfo', async (req, res, next) => {
33
33
 
34
34
  router.get('/nodeinfo/:nodeinfoVersion', async (req, res, next) => {
35
35
  const { nodeinfoVersion } = req.params
36
- const { keyStorage } = req.app.locals
36
+ const { keyStorage, actorStorage } = req.app.locals
37
37
  if (!VERSIONS.includes(nodeinfoVersion)) {
38
38
  throw new ProblemDetailsError(404, 'unsupported nodeinfo version')
39
39
  }
@@ -51,7 +51,9 @@ router.get('/nodeinfo/:nodeinfoVersion', async (req, res, next) => {
51
51
  openRegistrations: false,
52
52
  usage: {
53
53
  users: {
54
- total: await keyStorage.count()
54
+ total: await keyStorage.count(),
55
+ activeMonth: await actorStorage.activeUsers(30),
56
+ activeHalfyear: await actorStorage.activeUsers(180)
55
57
  }
56
58
  },
57
59
  metadata: {}
@@ -0,0 +1,112 @@
1
+ import fetch from 'node-fetch'
2
+ import dns from 'node:dns'
3
+ import dnsPromises from 'node:dns/promises'
4
+ import https from 'node:https'
5
+
6
+ import ipaddr from 'ipaddr.js'
7
+
8
+ function isPrivateIP (address) {
9
+ if (!ipaddr.isValid(address)) return false
10
+ const addr = ipaddr.parse(address)
11
+ const range = addr.range()
12
+ return range !== 'unicast'
13
+ }
14
+
15
+ class PrivateNetworkError extends Error {
16
+ constructor (address) {
17
+ super(`Private network address ${address}`)
18
+ this.name = 'PrivateNetworkError'
19
+ this.address = address
20
+ }
21
+ }
22
+
23
+ class SafeAgent extends https.Agent {
24
+ constructor (options = {}) {
25
+ super({
26
+ keepAlive: true,
27
+ keepAliveMsecs: 1000,
28
+ maxSockets: 64,
29
+ maxFreeSockets: 256,
30
+ ...options
31
+ })
32
+ }
33
+
34
+ createConnection (options, callback) {
35
+ dns.lookup(options.hostname, (err, address) => {
36
+ if (err) {
37
+ return callback(err)
38
+ }
39
+ if (isPrivateIP(address)) {
40
+ return callback(new PrivateNetworkError(address))
41
+ }
42
+ try {
43
+ const socket = super.createConnection({
44
+ ...options,
45
+ host: address,
46
+ servername: options.servername || options.hostname
47
+ })
48
+ callback(null, socket)
49
+ } catch (e) {
50
+ callback(e)
51
+ }
52
+ })
53
+ }
54
+ }
55
+
56
+ export class ProtocolError extends Error {
57
+ constructor (url) {
58
+ super(`URL ${url} uses a disallowed protocol`)
59
+ this.name = 'ProtocolError'
60
+ this.url = url
61
+ }
62
+ }
63
+
64
+ export class SafeFetcher {
65
+ #allowPrivate
66
+ #agent
67
+
68
+ constructor (options = {}) {
69
+ const { allowPrivate } = options
70
+ this.#allowPrivate = !!allowPrivate
71
+ this.#agent = (this.#allowPrivate)
72
+ ? null
73
+ : new SafeAgent()
74
+ }
75
+
76
+ get allowPrivateNetworkRequests () {
77
+ return this.#allowPrivate
78
+ }
79
+
80
+ async fetch (url, options) {
81
+ if (!(await this.#checkProtocol(url))) {
82
+ throw new ProtocolError(url)
83
+ }
84
+ const fullOptions = {
85
+ ...options,
86
+ agent: this.#agent ?? undefined,
87
+ signal: options?.signal ?? AbortSignal.timeout(10000),
88
+ size: 1024 * 1024,
89
+ follow: 10
90
+ }
91
+ return await fetch(url, fullOptions)
92
+ }
93
+
94
+ async #checkProtocol (url) {
95
+ const parsed = (new URL(url))
96
+ switch (parsed.protocol) {
97
+ case 'https:':
98
+ return true
99
+ case 'http:': {
100
+ if (this.#agent) {
101
+ return false
102
+ }
103
+ const { address } = await dnsPromises.lookup(parsed.hostname)
104
+ const addr = ipaddr.parse(address)
105
+ const range = addr.range()
106
+ return range !== 'unicast'
107
+ }
108
+ default:
109
+ return false
110
+ }
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.45.19",
3
+ "version": "0.46.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/lib/safeagent.js DELETED
@@ -1,52 +0,0 @@
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
- constructor (options = {}) {
23
- super({
24
- keepAlive: true,
25
- keepAliveMsecs: 1000,
26
- maxSockets: 64,
27
- maxFreeSockets: 256,
28
- ...options
29
- })
30
- }
31
-
32
- createConnection (options, callback) {
33
- dns.lookup(options.hostname, (err, address) => {
34
- if (err) {
35
- return callback(err)
36
- }
37
- if (isPrivateIP(address)) {
38
- return callback(new PrivateNetworkError(address))
39
- }
40
- try {
41
- const socket = super.createConnection({
42
- ...options,
43
- host: address,
44
- servername: options.servername || options.hostname
45
- })
46
- callback(null, socket)
47
- } catch (e) {
48
- callback(e)
49
- }
50
- })
51
- }
52
- }