@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.
@@ -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
- 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
- '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 ${url}`)
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 = (new URL(url)).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 ${url} with GET`)
97
- const result = await fetch(url,
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 }, 'updating limiter')
104
- await this.#limiter.update(hostname, result.headers)
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
- return result
107
- }
108
-
109
- async #handleRes (res, url) {
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({ status: res.status, body, url }, 'Could not fetch url')
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 ${url}`,
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 = (URL.parse(url).hash)
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 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.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",