@evanp/activitypub-bot 0.35.0 → 0.36.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 +84 -28
- 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,138 @@ 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
121
|
'user-agent': ActivityPubClient.#userAgent
|
|
86
122
|
}
|
|
123
|
+
if (cached?.lastModified) {
|
|
124
|
+
headers['if-modified-since'] = cached.lastModified.toUTCString()
|
|
125
|
+
}
|
|
126
|
+
if (cached?.etag) {
|
|
127
|
+
headers['if-none-match'] = cached.etag
|
|
128
|
+
}
|
|
129
|
+
this.#logger.debug({ headers }, 'Sending headers')
|
|
87
130
|
const method = 'GET'
|
|
88
|
-
this.#logger.debug(`Signing GET request for ${
|
|
131
|
+
this.#logger.debug(`Signing GET request for ${baseUrl}`)
|
|
89
132
|
if (sign) {
|
|
90
133
|
headers.signature =
|
|
91
|
-
await this.#sign({ username, url, method, headers })
|
|
134
|
+
await this.#sign({ username, url: baseUrl, method, headers })
|
|
92
135
|
}
|
|
93
|
-
const hostname =
|
|
94
|
-
this.#logger.debug({ url, hostname }, 'Waiting for rate limiter')
|
|
136
|
+
const hostname = parsed.hostname
|
|
137
|
+
this.#logger.debug({ url: baseUrl, hostname }, 'Waiting for rate limiter')
|
|
95
138
|
await this.#limiter.limit(hostname)
|
|
96
|
-
this.#logger.debug(`Fetching ${
|
|
97
|
-
const
|
|
139
|
+
this.#logger.debug(`Fetching ${baseUrl} with GET`)
|
|
140
|
+
const res = await fetch(baseUrl,
|
|
98
141
|
{
|
|
99
142
|
method,
|
|
100
143
|
headers
|
|
101
144
|
}
|
|
102
145
|
)
|
|
103
|
-
this.#logger.debug({ hostname }, '
|
|
104
|
-
await this.#limiter.update(hostname,
|
|
146
|
+
this.#logger.debug({ hostname, status: res.status }, 'response received')
|
|
147
|
+
await this.#limiter.update(hostname, res.headers)
|
|
105
148
|
this.#logger.debug(`Finished getting ${url}`)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (res.status < 200 || res.status > 299) {
|
|
149
|
+
if (useCache && res.status === 304) {
|
|
150
|
+
this.#logger.debug({ baseUrl }, '304 Not Modified, returning cached object')
|
|
151
|
+
return cached.object
|
|
152
|
+
} else if (res.status < 200 || res.status > 299) {
|
|
111
153
|
const body = await res.text()
|
|
112
|
-
this.#logger.warn(
|
|
154
|
+
this.#logger.warn(
|
|
155
|
+
{ status: res.status, body, url: baseUrl },
|
|
156
|
+
'Could not fetch url'
|
|
157
|
+
)
|
|
113
158
|
throw createHttpError(
|
|
114
159
|
res.status,
|
|
115
|
-
`Could not fetch ${
|
|
160
|
+
`Could not fetch ${baseUrl}`,
|
|
116
161
|
{ headers: res.headers }
|
|
117
162
|
)
|
|
118
163
|
}
|
|
164
|
+
|
|
119
165
|
const contentType = res.headers.get('content-type')
|
|
120
166
|
const mimeType = contentType?.split(';')[0].trim()
|
|
121
167
|
if (mimeType !== 'application/json' && !mimeType.endsWith('+json')) {
|
|
122
|
-
this.#logger.warn({ mimeType, url }, 'Unexpected mime type')
|
|
168
|
+
this.#logger.warn({ mimeType, url: baseUrl }, 'Unexpected mime type')
|
|
123
169
|
throw new Error(`Got unexpected mime type ${mimeType} for URL ${url}`)
|
|
124
170
|
}
|
|
125
171
|
let json
|
|
126
172
|
try {
|
|
127
173
|
json = await res.json()
|
|
128
174
|
} catch (err) {
|
|
129
|
-
this.#logger.warn({ url }, 'Error parsing fetch results')
|
|
175
|
+
this.#logger.warn({ url: baseUrl }, 'Error parsing fetch results')
|
|
130
176
|
throw err
|
|
131
177
|
}
|
|
178
|
+
|
|
179
|
+
await this.#cache.set(baseUrl, username, json, res.headers)
|
|
180
|
+
|
|
181
|
+
return json
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #getObj (url, json) {
|
|
185
|
+
const parsed = new URL(url)
|
|
186
|
+
const baseUrl = `${parsed.origin}${parsed.pathname}${parsed.search}`
|
|
187
|
+
|
|
132
188
|
let obj
|
|
133
189
|
try {
|
|
134
190
|
obj = await as2.import(json)
|
|
135
191
|
} catch (err) {
|
|
136
|
-
this.#logger.warn({ url, json }, 'Error importing JSON as AS2')
|
|
192
|
+
this.#logger.warn({ url: baseUrl, json }, 'Error importing JSON as AS2')
|
|
137
193
|
throw err
|
|
138
194
|
}
|
|
139
|
-
const resolved = (
|
|
195
|
+
const resolved = (parsed.hash)
|
|
140
196
|
? this.#resolveObject(obj, url)
|
|
141
197
|
: obj
|
|
142
198
|
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
|
+
}
|