@evanp/activitypub-bot 0.26.3 → 0.28.2

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.
@@ -33,13 +33,21 @@ export class ActivityPubClient {
33
33
  #signer = null
34
34
  #digester = null
35
35
  #logger = null
36
-
37
- constructor (keyStorage, urlFormatter, signer, digester, logger) {
36
+ #limiter
37
+
38
+ constructor (keyStorage, urlFormatter, signer, digester, logger, limiter) {
39
+ assert.strictEqual(typeof keyStorage, 'object')
40
+ assert.strictEqual(typeof urlFormatter, 'object')
41
+ assert.strictEqual(typeof signer, 'object')
42
+ assert.strictEqual(typeof digester, 'object')
43
+ assert.strictEqual(typeof logger, 'object')
44
+ assert.strictEqual(typeof limiter, 'object')
38
45
  this.#keyStorage = keyStorage
39
46
  this.#urlFormatter = urlFormatter
40
47
  this.#signer = signer
41
48
  this.#digester = digester
42
49
  this.#logger = logger.child({ class: this.constructor.name })
50
+ this.#limiter = limiter
43
51
  }
44
52
 
45
53
  async get (url, username = null) {
@@ -75,6 +83,9 @@ export class ActivityPubClient {
75
83
  headers.signature =
76
84
  await this.#sign({ username, url, method, headers })
77
85
  }
86
+ const hostname = (new URL(url)).hostname
87
+ this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
88
+ await this.#limiter.limit(hostname)
78
89
  this.#logger.debug(`Fetching ${url} with GET`)
79
90
  const result = await fetch(url,
80
91
  {
@@ -82,6 +93,8 @@ export class ActivityPubClient {
82
93
  headers
83
94
  }
84
95
  )
96
+ this.#logger.debug({ hostname }, 'updating limiter')
97
+ await this.#limiter.update(hostname, result.headers)
85
98
  this.#logger.debug(`Finished getting ${url}`)
86
99
  return result
87
100
  }
@@ -115,6 +128,9 @@ export class ActivityPubClient {
115
128
  assert.ok(headers)
116
129
  this.#logger.debug(`Signing POST for ${url}`)
117
130
  headers.signature = await this.#sign({ username, url, method, headers })
131
+ const hostname = (new URL(url)).hostname
132
+ this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
133
+ await this.#limiter.limit(hostname)
118
134
  this.#logger.debug(`Fetching POST for ${url}`)
119
135
  const res = await fetch(url,
120
136
  {
@@ -123,6 +139,8 @@ export class ActivityPubClient {
123
139
  body
124
140
  }
125
141
  )
142
+ this.#logger.debug({ hostname }, 'updating limiter')
143
+ await this.#limiter.update(hostname, res.headers)
126
144
  this.#logger.debug(`Done fetching POST for ${url}`)
127
145
  if (res.status < 200 || res.status > 299) {
128
146
  throw createHttpError(res.status, await res.text())
package/lib/app.js CHANGED
@@ -37,6 +37,7 @@ import { JobQueue } from './jobqueue.js'
37
37
  import { JobReaper } from './jobreaper.js'
38
38
  import { DeliveryWorker } from './deliveryworker.js'
39
39
  import { DistributionWorker } from './distributionworker.js'
40
+ import { RateLimiter } from '../lib/ratelimiter.js'
40
41
 
41
42
  const currentDir = dirname(fileURLToPath(import.meta.url))
42
43
  const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
@@ -57,8 +58,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
57
58
  const botDataStorage = new BotDataStorage(connection)
58
59
  const keyStorage = new KeyStorage(connection, logger)
59
60
  const objectStorage = new ObjectStorage(connection)
60
- const client =
61
- new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
61
+ const limiter = new RateLimiter(connection, logger)
62
+ const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter)
62
63
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
63
64
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
64
65
  const jobQueue = new JobQueue(connection, logger)
@@ -0,0 +1,13 @@
1
+ export const id = '005-rate-limit'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ CREATE TABLE rate_limit (
6
+ host varchar(256) NOT NULL PRIMARY KEY,
7
+ remaining INTEGER default 0,
8
+ reset TIMESTAMP,
9
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
11
+ );
12
+ `, queryOptions)
13
+ }
@@ -0,0 +1,109 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises'
2
+ import assert from 'node:assert'
3
+
4
+ export class RateLimiter {
5
+ #connection
6
+ #logger
7
+
8
+ constructor (connection, logger) {
9
+ assert.strictEqual(typeof connection, 'object')
10
+ assert.strictEqual(typeof logger, 'object')
11
+ this.#connection = connection
12
+ this.#logger = logger
13
+ }
14
+
15
+ async limit (host, maxWaitTime = 30000) {
16
+ assert.strictEqual(typeof host, 'string')
17
+ assert.strictEqual(typeof maxWaitTime, 'number')
18
+ const waitTime = await this.#getWaitTime(host)
19
+ if (waitTime > maxWaitTime) {
20
+ throw new Error(`Wait time is too long; ${waitTime} > ${maxWaitTime}`)
21
+ }
22
+ if (waitTime > 0) {
23
+ await this.#decrement(host)
24
+ await sleep(waitTime)
25
+ }
26
+ }
27
+
28
+ async update (host, headers) {
29
+ assert.strictEqual(typeof host, 'string')
30
+ assert.strictEqual(typeof headers, 'object')
31
+
32
+ const resetHeader = headers.get('x-ratelimit-reset')
33
+ const remainingHeader = headers.get('x-ratelimit-remaining')
34
+
35
+ if (resetHeader && remainingHeader) {
36
+ const remaining = parseInt(remainingHeader)
37
+ const resetSeconds = parseInt(resetHeader)
38
+ const reset = new Date(Date.now() + (resetSeconds * 1000))
39
+ this.#logger.debug({ reset, remaining, host }, 'updating')
40
+ await this.#connection.query(
41
+ `INSERT INTO rate_limit (host, remaining, reset)
42
+ VALUES (?, ?, ?)
43
+ ON CONFLICT (host) DO UPDATE
44
+ SET remaining = EXCLUDED.remaining,
45
+ reset = EXCLUDED.reset,
46
+ updated_at = CURRENT_TIMESTAMP`,
47
+ { replacements: [host, remaining, reset] }
48
+ )
49
+ }
50
+ }
51
+
52
+ async #getWaitTime (host) {
53
+ assert.strictEqual(typeof host, 'string')
54
+
55
+ const [result] = await this.#connection.query(
56
+ 'SELECT remaining, reset FROM rate_limit WHERE host = ?',
57
+ { replacements: [host] }
58
+ )
59
+
60
+ if (result.length === 0) {
61
+ return 0
62
+ }
63
+
64
+ const { remaining, reset } = result[0]
65
+ const resetDate = new Date(reset)
66
+ const now = new Date()
67
+
68
+ if (now > resetDate) {
69
+ this.#logger.debug(
70
+ { remaining, resetDate, host },
71
+ 'past epoch'
72
+ )
73
+ return 0
74
+ }
75
+
76
+ if (remaining === 0) {
77
+ this.#logger.debug(
78
+ { remaining, resetDate, host },
79
+ 'no more requests remaining'
80
+ )
81
+ return Math.round(resetDate - now)
82
+ }
83
+
84
+ this.#logger.debug(
85
+ { remaining, resetDate, host },
86
+ 'no more requests remaining'
87
+ )
88
+
89
+ const waitTime = Math.round((resetDate - now) / (remaining * 1.0))
90
+
91
+ this.#logger.debug(
92
+ { remaining, resetDate, host, waitTime },
93
+ 'no more requests remaining'
94
+ )
95
+ return waitTime
96
+ }
97
+
98
+ async #decrement (host) {
99
+ assert.strictEqual(typeof host, 'string')
100
+
101
+ await this.#connection.query(
102
+ `UPDATE rate_limit
103
+ SET remaining = remaining - 1,
104
+ updated_at = CURRENT_TIMESTAMP
105
+ WHERE host = ?`,
106
+ { replacements: [host] }
107
+ )
108
+ }
109
+ }
@@ -4,37 +4,32 @@ import as2 from '../activitystreams.js'
4
4
  const router = express.Router()
5
5
 
6
6
  router.get('/', async (req, res) => {
7
- const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
8
- const sendJson = async () => {
9
- const { formatter } = req.app.locals
10
- const server = await as2.import({
11
- '@context': [
12
- 'https://www.w3.org/ns/activitystreams',
13
- 'https://w3id.org/security/v1'
14
- ],
15
- id: formatter.format({ server: true }),
16
- type: 'Service',
17
- publicKey: formatter.format({ server: true, type: 'publickey' }),
18
- url: {
19
- type: 'Link',
20
- mediaType: 'text/html',
21
- href: fullUrl
22
- }
23
- })
24
- const body = await server.export({ useOriginalContext: true })
25
- res.status(200)
26
- res.type(as2.mediaType)
27
- res.json(body)
28
- }
29
- res.format({
30
- html: async () => {
31
- const { indexFileName } = req.app.locals
32
- res.sendFile(indexFileName)
33
- },
34
- 'application/activity+json': sendJson,
35
- 'application/ld+json': sendJson,
36
- json: sendJson
7
+ const { indexFileName } = req.app.locals
8
+ res.type('html')
9
+ res.sendFile(indexFileName)
10
+ })
11
+
12
+ router.get('/actor', async (req, res) => {
13
+ const homepage = `${req.protocol}://${req.get('host')}/`
14
+ const { formatter } = req.app.locals
15
+ const server = await as2.import({
16
+ '@context': [
17
+ 'https://www.w3.org/ns/activitystreams',
18
+ 'https://w3id.org/security/v1'
19
+ ],
20
+ id: formatter.format({ server: true }),
21
+ type: 'Service',
22
+ publicKey: formatter.format({ server: true, type: 'publickey' }),
23
+ url: {
24
+ type: 'Link',
25
+ mediaType: 'text/html',
26
+ href: homepage
27
+ }
37
28
  })
29
+ const body = await server.export({ useOriginalContext: true })
30
+ res.status(200)
31
+ res.type(as2.mediaType)
32
+ res.json(body)
38
33
  })
39
34
 
40
35
  router.get('/publickey', async (req, res) => {
@@ -41,7 +41,7 @@ export class UrlFormatter {
41
41
  }
42
42
  // For the base case, we want a trailing slash.
43
43
  if (url === this.#origin) {
44
- url = `${url}/`
44
+ url = `${url}/actor`
45
45
  }
46
46
  return url
47
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.26.3",
3
+ "version": "0.28.2",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -43,7 +43,7 @@
43
43
  "sequelize": "^6.37.7"
44
44
  },
45
45
  "devDependencies": {
46
- "@evanp/activitypub-nock": "^0.6.0",
46
+ "@evanp/activitypub-nock": "^0.7.0",
47
47
  "eslint": "^8.57.1",
48
48
  "eslint-config-standard": "^17.1.0",
49
49
  "eslint-plugin-import": "^2.29.1",
@@ -55,7 +55,7 @@
55
55
  "optionalDependencies": {
56
56
  "mysql2": "^3.9.1",
57
57
  "pg": "^8.16.3",
58
- "sqlite3": "^5.1.7"
58
+ "sqlite3": "^6.0.1"
59
59
  },
60
60
  "engines": {
61
61
  "node": "^20 || ^22 || ^24 || ^25"