@evanp/activitypub-bot 0.26.2 → 0.27.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/lib/activitypubclient.js +20 -2
- package/lib/app.js +3 -2
- package/lib/migrations/005-rate-limit.js +13 -0
- package/lib/ratelimiter.js +109 -0
- package/lib/routes/server.js +24 -21
- package/package.json +2 -2
package/lib/activitypubclient.js
CHANGED
|
@@ -33,13 +33,21 @@ export class ActivityPubClient {
|
|
|
33
33
|
#signer = null
|
|
34
34
|
#digester = null
|
|
35
35
|
#logger = null
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
61
|
-
|
|
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
|
+
}
|
package/lib/routes/server.js
CHANGED
|
@@ -5,32 +5,35 @@ const router = express.Router()
|
|
|
5
5
|
|
|
6
6
|
router.get('/', async (req, res) => {
|
|
7
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
|
+
}
|
|
8
29
|
res.format({
|
|
9
30
|
html: async () => {
|
|
10
31
|
const { indexFileName } = req.app.locals
|
|
11
32
|
res.sendFile(indexFileName)
|
|
12
33
|
},
|
|
13
|
-
json:
|
|
14
|
-
|
|
15
|
-
|
|
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: fullUrl
|
|
27
|
-
}
|
|
28
|
-
})
|
|
29
|
-
const body = await server.export({ useOriginalContext: true })
|
|
30
|
-
res.status(200)
|
|
31
|
-
res.type(as2.mediaType)
|
|
32
|
-
res.json(body)
|
|
33
|
-
}
|
|
34
|
+
'application/activity+json': sendJson,
|
|
35
|
+
'application/ld+json': sendJson,
|
|
36
|
+
json: sendJson
|
|
34
37
|
})
|
|
35
38
|
})
|
|
36
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evanp/activitypub-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.1",
|
|
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.
|
|
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",
|