@evanp/activitypub-bot 0.35.0 → 0.36.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 +81 -29
- package/lib/app.js +3 -1
- package/lib/migrations/007-remote-object-cache.js +20 -0
- package/lib/remoteobjectcache.js +152 -0
- package/package.json +1 -1
package/lib/activitypubclient.js
CHANGED
|
@@ -36,20 +36,23 @@ export class ActivityPubClient {
|
|
|
36
36
|
#digester = null
|
|
37
37
|
#logger = null
|
|
38
38
|
#limiter
|
|
39
|
+
#cache
|
|
39
40
|
|
|
40
|
-
constructor (keyStorage, urlFormatter, signer, digester, logger, limiter) {
|
|
41
|
+
constructor (keyStorage, urlFormatter, signer, digester, logger, limiter, cache) {
|
|
41
42
|
assert.strictEqual(typeof keyStorage, 'object')
|
|
42
43
|
assert.strictEqual(typeof urlFormatter, 'object')
|
|
43
44
|
assert.strictEqual(typeof signer, 'object')
|
|
44
45
|
assert.strictEqual(typeof digester, 'object')
|
|
45
46
|
assert.strictEqual(typeof logger, 'object')
|
|
46
47
|
assert.strictEqual(typeof limiter, 'object')
|
|
48
|
+
assert.strictEqual(typeof cache, 'object')
|
|
47
49
|
this.#keyStorage = keyStorage
|
|
48
50
|
this.#urlFormatter = urlFormatter
|
|
49
51
|
this.#signer = signer
|
|
50
52
|
this.#digester = digester
|
|
51
53
|
this.#logger = logger.child({ class: this.constructor.name })
|
|
52
54
|
this.#limiter = limiter
|
|
55
|
+
this.#cache = cache
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
async get (url, username = this.#urlFormatter.hostname) {
|
|
@@ -58,85 +61,134 @@ export class ActivityPubClient {
|
|
|
58
61
|
assert.ok(username)
|
|
59
62
|
assert.equal(typeof username, 'string')
|
|
60
63
|
assert.ok(username !== '*')
|
|
61
|
-
|
|
62
|
-
return await this.#
|
|
64
|
+
|
|
65
|
+
return await this.#get(url, username, true)
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
async getKey (url) {
|
|
66
69
|
assert.ok(url)
|
|
67
70
|
assert.equal(typeof url, 'string')
|
|
68
|
-
let
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
let obj
|
|
72
|
+
try {
|
|
73
|
+
obj = await this.#get(url, this.#urlFormatter.hostname, false, false)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.status && [401, 403, 404].includes(err.status)) {
|
|
76
|
+
obj = await this.#get(url, this.#urlFormatter.hostname, true, false)
|
|
77
|
+
} else {
|
|
78
|
+
throw err
|
|
79
|
+
}
|
|
72
80
|
}
|
|
73
|
-
return
|
|
81
|
+
return obj
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async #get (url, username = this.#urlFormatter.hostname, sign = false, useCache = true) {
|
|
85
|
+
const json = await this.#getJSON(url, username, sign, useCache)
|
|
86
|
+
const obj = await this.#getObj(url, json)
|
|
87
|
+
return obj
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
async #
|
|
90
|
+
async #getJSON (url, username = this.#urlFormatter.hostname, sign = false, useCache = true) {
|
|
77
91
|
assert.ok(url)
|
|
78
92
|
assert.equal(typeof url, 'string')
|
|
79
93
|
assert.ok(username)
|
|
80
94
|
assert.equal(typeof username, 'string')
|
|
95
|
+
|
|
96
|
+
const parsed = new URL(url)
|
|
97
|
+
const baseUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`
|
|
98
|
+
let cached
|
|
99
|
+
|
|
100
|
+
if (useCache) {
|
|
101
|
+
cached = await this.#cache.get(baseUrl, username)
|
|
102
|
+
this.#logger.debug({ baseUrl, username, cached: !!cached, expiry: cached?.expiry }, 'cache lookup')
|
|
103
|
+
|
|
104
|
+
if (cached) {
|
|
105
|
+
this.#logger.debug({ baseUrl }, 'cache hit')
|
|
106
|
+
const now = Date.now()
|
|
107
|
+
const expiry = cached.expiry.getTime()
|
|
108
|
+
if (expiry > now) {
|
|
109
|
+
this.#logger.debug({ baseUrl, expiry, now }, 'cache fresh, returning cached object')
|
|
110
|
+
return cached.object
|
|
111
|
+
} else {
|
|
112
|
+
this.#logger.debug({ baseUrl, expiry, now }, 'cache stale, revalidating')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
81
117
|
const date = new Date().toUTCString()
|
|
82
118
|
const headers = {
|
|
83
119
|
accept: ActivityPubClient.#accept,
|
|
84
120
|
date,
|
|
85
|
-
'user-agent': ActivityPubClient.#userAgent
|
|
121
|
+
'user-agent': ActivityPubClient.#userAgent,
|
|
122
|
+
'if-modified-since': cached?.lastModified?.toUTCString(),
|
|
123
|
+
'if-none-match': cached?.etag
|
|
86
124
|
}
|
|
125
|
+
this.#logger.debug({ headers }, 'Sending headers')
|
|
87
126
|
const method = 'GET'
|
|
88
|
-
this.#logger.debug(`Signing GET request for ${
|
|
127
|
+
this.#logger.debug(`Signing GET request for ${baseUrl}`)
|
|
89
128
|
if (sign) {
|
|
90
129
|
headers.signature =
|
|
91
|
-
await this.#sign({ username, url, method, headers })
|
|
130
|
+
await this.#sign({ username, url: baseUrl, method, headers })
|
|
92
131
|
}
|
|
93
|
-
const hostname =
|
|
94
|
-
this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
|
|
132
|
+
const hostname = parsed.hostname
|
|
133
|
+
this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
|
|
95
134
|
await this.#limiter.limit(hostname)
|
|
96
|
-
this.#logger.debug(`Fetching ${
|
|
97
|
-
const
|
|
135
|
+
this.#logger.debug(`Fetching ${baseUrl} with GET`)
|
|
136
|
+
const res = await fetch(baseUrl,
|
|
98
137
|
{
|
|
99
138
|
method,
|
|
100
139
|
headers
|
|
101
140
|
}
|
|
102
141
|
)
|
|
103
|
-
this.#logger.debug({ hostname }, '
|
|
104
|
-
await this.#limiter.update(hostname,
|
|
142
|
+
this.#logger.debug({ hostname, status: res.status }, 'response received')
|
|
143
|
+
await this.#limiter.update(hostname, res.headers)
|
|
105
144
|
this.#logger.debug(`Finished getting ${url}`)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (res.status < 200 || res.status > 299) {
|
|
145
|
+
if (useCache && res.status === 304) {
|
|
146
|
+
this.#logger.debug({ baseUrl }, '304 Not Modified, returning cached object')
|
|
147
|
+
return cached.object
|
|
148
|
+
} else if (res.status < 200 || res.status > 299) {
|
|
111
149
|
const body = await res.text()
|
|
112
|
-
this.#logger.warn(
|
|
150
|
+
this.#logger.warn(
|
|
151
|
+
{ status: res.status, body, url: baseUrl },
|
|
152
|
+
'Could not fetch url'
|
|
153
|
+
)
|
|
113
154
|
throw createHttpError(
|
|
114
155
|
res.status,
|
|
115
|
-
`Could not fetch ${
|
|
156
|
+
`Could not fetch ${baseUrl}`,
|
|
116
157
|
{ headers: res.headers }
|
|
117
158
|
)
|
|
118
159
|
}
|
|
160
|
+
|
|
119
161
|
const contentType = res.headers.get('content-type')
|
|
120
162
|
const mimeType = contentType?.split(';')[0].trim()
|
|
121
163
|
if (mimeType !== 'application/json' && !mimeType.endsWith('+json')) {
|
|
122
|
-
this.#logger.warn({ mimeType, url }, 'Unexpected mime type')
|
|
164
|
+
this.#logger.warn({ mimeType, url: baseUrl }, 'Unexpected mime type')
|
|
123
165
|
throw new Error(`Got unexpected mime type ${mimeType} for URL ${url}`)
|
|
124
166
|
}
|
|
125
167
|
let json
|
|
126
168
|
try {
|
|
127
169
|
json = await res.json()
|
|
128
170
|
} catch (err) {
|
|
129
|
-
this.#logger.warn({ url }, 'Error parsing fetch results')
|
|
171
|
+
this.#logger.warn({ url: baseUrl }, 'Error parsing fetch results')
|
|
130
172
|
throw err
|
|
131
173
|
}
|
|
174
|
+
|
|
175
|
+
await this.#cache.set(baseUrl, username, json, res.headers)
|
|
176
|
+
|
|
177
|
+
return json
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #getObj (url, json) {
|
|
181
|
+
const parsed = new URL(url)
|
|
182
|
+
const baseUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`
|
|
183
|
+
|
|
132
184
|
let obj
|
|
133
185
|
try {
|
|
134
186
|
obj = await as2.import(json)
|
|
135
187
|
} catch (err) {
|
|
136
|
-
this.#logger.warn({ url, json }, 'Error importing JSON as AS2')
|
|
188
|
+
this.#logger.warn({ url: baseUrl, json }, 'Error importing JSON as AS2')
|
|
137
189
|
throw err
|
|
138
190
|
}
|
|
139
|
-
const resolved = (
|
|
191
|
+
const resolved = (parsed.hash)
|
|
140
192
|
? this.#resolveObject(obj, url)
|
|
141
193
|
: obj
|
|
142
194
|
return resolved
|
package/lib/app.js
CHANGED
|
@@ -43,6 +43,7 @@ import { RateLimiter } from '../lib/ratelimiter.js'
|
|
|
43
43
|
import DoNothingBot from './bots/donothing.js'
|
|
44
44
|
import { FanoutWorker } from './fanoutworker.js'
|
|
45
45
|
import { IntakeWorker } from './intakeworker.js'
|
|
46
|
+
import { RemoteObjectCache } from './remoteobjectcache.js'
|
|
46
47
|
|
|
47
48
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
48
49
|
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
@@ -71,7 +72,8 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
71
72
|
const keyStorage = new KeyStorage(connection, logger)
|
|
72
73
|
const objectStorage = new ObjectStorage(connection)
|
|
73
74
|
const limiter = new RateLimiter(connection, logger)
|
|
74
|
-
const
|
|
75
|
+
const remoteObjectCache = new RemoteObjectCache(connection, logger)
|
|
76
|
+
const client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter, remoteObjectCache)
|
|
75
77
|
const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
|
|
76
78
|
const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
|
|
77
79
|
const jobQueue = new JobQueue(connection, logger)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const id = '007-remote-object-cache'
|
|
2
|
+
|
|
3
|
+
export async function up (connection, queryOptions = {}) {
|
|
4
|
+
await connection.query(`
|
|
5
|
+
CREATE TABLE remote_object_cache (
|
|
6
|
+
id varchar(512) NOT NULL,
|
|
7
|
+
username varchar(512) NOT NULL,
|
|
8
|
+
last_modified TIMESTAMP NULL,
|
|
9
|
+
etag varchar(256) NULL,
|
|
10
|
+
expiry TIMESTAMP NULL,
|
|
11
|
+
data TEXT NOT NULL,
|
|
12
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
13
|
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
14
|
+
PRIMARY KEY (id, username)
|
|
15
|
+
);
|
|
16
|
+
`, queryOptions)
|
|
17
|
+
await connection.query(`
|
|
18
|
+
CREATE INDEX remote_object_cache_expiry on remote_object_cache (expiry);
|
|
19
|
+
`, queryOptions)
|
|
20
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import as2 from './activitystreams.js'
|
|
4
|
+
|
|
5
|
+
const SEC_NS = 'https://w3id.org/security#'
|
|
6
|
+
const NS = 'https://www.w3.org/ns/activitystreams#'
|
|
7
|
+
const KEY_TYPES = [SEC_NS + 'Key', SEC_NS + 'CryptographicKey']
|
|
8
|
+
const COLLECTION_TYPES =
|
|
9
|
+
[NS + 'Collection', NS + 'CollectionPage', NS + 'OrderedCollection', NS + 'OrderedCollectionPage']
|
|
10
|
+
|
|
11
|
+
export class RemoteObjectCache {
|
|
12
|
+
#connection
|
|
13
|
+
#logger
|
|
14
|
+
constructor (connection, logger) {
|
|
15
|
+
assert.ok(connection)
|
|
16
|
+
assert.ok(logger)
|
|
17
|
+
this.#connection = connection
|
|
18
|
+
this.#logger = logger
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async get (id, username) {
|
|
22
|
+
assert.ok(id)
|
|
23
|
+
assert.strictEqual(typeof id, 'string')
|
|
24
|
+
assert.ok(username)
|
|
25
|
+
assert.strictEqual(typeof username, 'string')
|
|
26
|
+
|
|
27
|
+
let result
|
|
28
|
+
|
|
29
|
+
const [rows] = await this.#connection.query(
|
|
30
|
+
`SELECT last_modified, etag, expiry, data
|
|
31
|
+
FROM remote_object_cache
|
|
32
|
+
WHERE id = ? AND username = ?`,
|
|
33
|
+
{ replacements: [id, username] })
|
|
34
|
+
|
|
35
|
+
if (rows.length === 0) {
|
|
36
|
+
result = null
|
|
37
|
+
} else {
|
|
38
|
+
result = {
|
|
39
|
+
expiry: new Date(rows[0].expiry),
|
|
40
|
+
lastModified: rows[0].last_modified
|
|
41
|
+
? new Date(rows[0].last_modified)
|
|
42
|
+
: null,
|
|
43
|
+
etag: rows[0].etag,
|
|
44
|
+
object: JSON.parse(rows[0].data)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
assert.ok(
|
|
49
|
+
result === null ||
|
|
50
|
+
(
|
|
51
|
+
typeof result === 'object' &&
|
|
52
|
+
'expiry' in result &&
|
|
53
|
+
'lastModified' in result &&
|
|
54
|
+
'etag' in result &&
|
|
55
|
+
'object' in result
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async set (id, username, object, headers) {
|
|
63
|
+
assert.ok(id)
|
|
64
|
+
assert.strictEqual(typeof id, 'string')
|
|
65
|
+
assert.ok(username)
|
|
66
|
+
assert.strictEqual(typeof username, 'string')
|
|
67
|
+
assert.ok(object)
|
|
68
|
+
assert.strictEqual(typeof object, 'object')
|
|
69
|
+
assert.ok(headers)
|
|
70
|
+
assert.strictEqual(typeof headers, 'object')
|
|
71
|
+
assert.ok(typeof headers.get === 'function')
|
|
72
|
+
|
|
73
|
+
const cacheControl = headers.get('cache-control')
|
|
74
|
+
if (cacheControl && cacheControl.includes('no-store')) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const expiry = await this.#getExpiry(object, headers)
|
|
79
|
+
const lastModified = headers.get('last-modified')
|
|
80
|
+
const etag = headers.get('etag')
|
|
81
|
+
|
|
82
|
+
await this.#connection.query(
|
|
83
|
+
`INSERT INTO remote_object_cache
|
|
84
|
+
(id, username, last_modified, etag, expiry, data)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
86
|
+
ON CONFLICT (id, username)
|
|
87
|
+
DO UPDATE
|
|
88
|
+
SET last_modified = EXCLUDED.last_modified,
|
|
89
|
+
etag = EXCLUDED.etag,
|
|
90
|
+
expiry = EXCLUDED.expiry,
|
|
91
|
+
data = EXCLUDED.data,
|
|
92
|
+
updated_at = CURRENT_TIMESTAMP
|
|
93
|
+
`,
|
|
94
|
+
{ replacements: [id, username, lastModified, etag, expiry, JSON.stringify(object)] }
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async #getExpiry (object, headers) {
|
|
99
|
+
assert.ok(object)
|
|
100
|
+
assert.strictEqual(typeof object, 'object')
|
|
101
|
+
assert.ok(headers)
|
|
102
|
+
assert.strictEqual(typeof headers, 'object')
|
|
103
|
+
assert.ok(typeof headers.get === 'function')
|
|
104
|
+
|
|
105
|
+
let expiry = this.#getExpiryFromHeaders(headers)
|
|
106
|
+
if (!expiry) {
|
|
107
|
+
expiry = await this.#getExpiryFromObject(object)
|
|
108
|
+
}
|
|
109
|
+
return expiry
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#getExpiryFromHeaders (headers) {
|
|
113
|
+
assert.ok(headers)
|
|
114
|
+
assert.strictEqual(typeof headers, 'object')
|
|
115
|
+
assert.ok(typeof headers.get === 'function')
|
|
116
|
+
|
|
117
|
+
const cacheControl = headers.get('cache-control')
|
|
118
|
+
if (cacheControl) {
|
|
119
|
+
if (cacheControl.includes('no-cache')) {
|
|
120
|
+
return new Date(Date.now() - 1)
|
|
121
|
+
} else if (cacheControl.includes('max-age')) {
|
|
122
|
+
const match = cacheControl.match(/max-age=(\d+)/)
|
|
123
|
+
if (match) {
|
|
124
|
+
return new Date(Date.now() + parseInt(match[1], 10) * 1000)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (headers.has('expires')) {
|
|
129
|
+
return new Date(headers.get('expires'))
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #getExpiryFromObject (object) {
|
|
135
|
+
assert.ok(object)
|
|
136
|
+
assert.strictEqual(typeof object, 'object')
|
|
137
|
+
const as2obj = await as2.import(object)
|
|
138
|
+
let offset
|
|
139
|
+
if (as2obj.isActivity()) {
|
|
140
|
+
offset = 24 * 60 * 60 * 1000
|
|
141
|
+
} else if (as2obj.inbox) {
|
|
142
|
+
offset = 30 * 60 * 1000
|
|
143
|
+
} else if (KEY_TYPES.includes(as2obj.type)) {
|
|
144
|
+
offset = -1000
|
|
145
|
+
} else if (COLLECTION_TYPES.includes(as2obj.type)) {
|
|
146
|
+
offset = -1000
|
|
147
|
+
} else {
|
|
148
|
+
offset = 5 * 60 * 1000
|
|
149
|
+
}
|
|
150
|
+
return new Date(Date.now() + offset)
|
|
151
|
+
}
|
|
152
|
+
}
|