@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.
@@ -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
- const res = await this.#getRes(url, username, true)
62
- return await this.#handleRes(res, url)
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 res = await this.#getRes(url, this.#urlFormatter.hostname, false)
69
- if ([401, 403, 404].includes(res.status)) {
70
- // If we get a 401, 403, or 404, we should try again with the key
71
- res = await this.#getRes(url, this.#urlFormatter.hostname, true)
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 await this.#handleRes(res, url)
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 #getRes (url, username = this.#urlFormatter.hostname, sign = false) {
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 ${url}`)
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 = (new URL(url)).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 ${url} with GET`)
97
- const result = await fetch(url,
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 }, 'updating limiter')
104
- await this.#limiter.update(hostname, result.headers)
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
- return result
107
- }
108
-
109
- async #handleRes (res, url) {
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({ status: res.status, body, url }, 'Could not fetch url')
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 ${url}`,
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 = (URL.parse(url).hash)
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 client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger, limiter)
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.35.0",
3
+ "version": "0.36.1",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",