@evanp/activitypub-bot 0.13.0 → 0.13.2

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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/.github/dependabot.yml +0 -11
  3. package/.github/workflows/main.yml +0 -34
  4. package/.github/workflows/tag.yml +0 -106
  5. package/.nvmrc +0 -1
  6. package/Dockerfile +0 -17
  7. package/docs/activitypub.bot.drawio +0 -110
  8. package/tests/activitydistributor.test.js +0 -606
  9. package/tests/activityhandler.test.js +0 -2276
  10. package/tests/activitypubclient.test.js +0 -210
  11. package/tests/actorstorage.test.js +0 -283
  12. package/tests/app.test.js +0 -17
  13. package/tests/authorizer.test.js +0 -301
  14. package/tests/bot.donothing.test.js +0 -30
  15. package/tests/bot.ok.test.js +0 -101
  16. package/tests/botcontext.test.js +0 -720
  17. package/tests/botdatastorage.test.js +0 -88
  18. package/tests/botfactory.provincebotfactory.test.js +0 -430
  19. package/tests/digester.test.js +0 -56
  20. package/tests/fixtures/bots.js +0 -27
  21. package/tests/fixtures/eventloggingbot.js +0 -57
  22. package/tests/fixtures/provincebotfactory.js +0 -53
  23. package/tests/httpsignature.test.js +0 -199
  24. package/tests/httpsignatureauthenticator.test.js +0 -463
  25. package/tests/index.test.js +0 -12
  26. package/tests/keystorage.test.js +0 -124
  27. package/tests/microsyntax.test.js +0 -123
  28. package/tests/objectcache.test.js +0 -133
  29. package/tests/objectstorage.test.js +0 -149
  30. package/tests/remotekeystorage.test.js +0 -78
  31. package/tests/routes.actor.test.js +0 -214
  32. package/tests/routes.collection.test.js +0 -310
  33. package/tests/routes.health.test.js +0 -41
  34. package/tests/routes.inbox.test.js +0 -216
  35. package/tests/routes.object.test.js +0 -525
  36. package/tests/routes.server.test.js +0 -69
  37. package/tests/routes.sharedinbox.test.js +0 -473
  38. package/tests/routes.webfinger.test.js +0 -68
  39. package/tests/urlformatter.test.js +0 -164
  40. package/tests/utils/digest.js +0 -7
  41. package/tests/utils/nock.js +0 -499
@@ -1,7 +0,0 @@
1
- import crypto from 'node:crypto'
2
-
3
- export function makeDigest (body) {
4
- const digest = crypto.createHash('sha256')
5
- digest.update(body)
6
- return `sha-256=${digest.digest('base64')}`
7
- }
@@ -1,499 +0,0 @@
1
- import as2 from '../../lib/activitystreams.js'
2
- import nock from 'nock'
3
- import crypto from 'node:crypto'
4
- import { promisify } from 'node:util'
5
-
6
- const PAGE_SIZE = 20
7
-
8
- const generateKeyPair = promisify(crypto.generateKeyPair)
9
-
10
- const defaultDomain = 'social.example'
11
-
12
- const domains = new Map()
13
- domains[defaultDomain] = new Map()
14
-
15
- const graph = new Map()
16
- graph[defaultDomain] = new Map()
17
-
18
- const collections = new Map()
19
- collections[defaultDomain] = new Map()
20
-
21
- const newKeyPair = async () => {
22
- return await generateKeyPair(
23
- 'rsa',
24
- {
25
- modulusLength: 2048,
26
- privateKeyEncoding: {
27
- type: 'pkcs8',
28
- format: 'pem'
29
- },
30
- publicKeyEncoding: {
31
- type: 'spki',
32
- format: 'pem'
33
- }
34
- }
35
- )
36
- }
37
-
38
- export const getPair = async (username, domain = defaultDomain) => {
39
- if (!domains.has(domain)) {
40
- domains.set(domain, new Map())
41
- }
42
- if (!domains.get(domain).has(username)) {
43
- const pair = await newKeyPair(username)
44
- domains.get(domain).set(username, pair)
45
- }
46
- return domains.get(domain).get(username)
47
- }
48
-
49
- export const getPublicKey = async (username, domain = defaultDomain) => {
50
- const pair = await getPair(username, domain)
51
- return pair.publicKey
52
- }
53
-
54
- export const getPrivateKey = async (username, domain = defaultDomain) => {
55
- const pair = await getPair(username, domain)
56
- return pair.privateKey
57
- }
58
-
59
- function ensureGraph (domain, username) {
60
- if (!graph.has(domain)) {
61
- graph.set(domain, new Map())
62
- }
63
- if (!graph.get(domain).has(username)) {
64
- graph.get(domain).set(username, new Map())
65
- graph.get(domain).get(username).set('followers', [])
66
- graph.get(domain).get(username).set('following', [])
67
- }
68
- return graph.get(domain).get(username)
69
- }
70
-
71
- export function addFollower (username, id, domain = defaultDomain) {
72
- ensureGraph(domain, username).get('followers').unshift(id)
73
- }
74
-
75
- export function addFollowing (username, id, domain = defaultDomain) {
76
- ensureGraph(domain, username).get('following').unshift(id)
77
- }
78
-
79
- function ensureCollection (domain, username, collection) {
80
- if (!collections.has(domain)) {
81
- collections.set(domain, new Map())
82
- }
83
- const dc = collections.get(domain)
84
- if (!dc.has(username)) {
85
- dc.set(username, new Map())
86
- }
87
- const dcu = dc.get(username)
88
- if (!dcu.has(collection)) {
89
- dcu.set(collection, [])
90
- }
91
- const dcuc = dcu.get(collection)
92
- return dcuc
93
- }
94
-
95
- export function addToCollection (username, collection, item, domain = defaultDomain) {
96
- ensureCollection(domain, username, collection).unshift(item)
97
- }
98
-
99
- export const nockSignature = async ({ method = 'GET', url, date, digest = null, username, domain = defaultDomain }) => {
100
- const privateKey = await getPrivateKey(username, domain)
101
- const keyId = nockFormat({ username, key: true, domain })
102
- const parsed = new URL(url)
103
- const target = (parsed.search && parsed.search.length)
104
- ? `${parsed.pathname}${parsed.search}`
105
- : `${parsed.pathname}`
106
- let data = `(request-target): ${method.toLowerCase()} ${target}\n`
107
- data += `host: ${parsed.host}\n`
108
- data += `date: ${date}`
109
- if (digest) {
110
- data += `\ndigest: ${digest}`
111
- }
112
- const signer = crypto.createSign('sha256')
113
- signer.update(data)
114
- const signature = signer.sign(privateKey).toString('base64')
115
- signer.end()
116
- return `keyId="${keyId}",headers="(request-target) host date${(digest) ? ' digest' : ''}",signature="${signature.replace(/"/g, '\\"')}",algorithm="rsa-sha256"`
117
- }
118
-
119
- export const nockSignatureFragment = async ({ method = 'GET', url, date, digest = null, username, domain = defaultDomain }) => {
120
- const keyId = nockFormat({ username, domain }) + '#main-key'
121
- const privateKey = await getPrivateKey(username, domain)
122
- const parsed = new URL(url)
123
- const target = (parsed.search && parsed.search.length)
124
- ? `${parsed.pathname}?${parsed.search}`
125
- : `${parsed.pathname}`
126
- let data = `(request-target): ${method.toLowerCase()} ${target}\n`
127
- data += `host: ${parsed.host}\n`
128
- data += `date: ${date}`
129
- if (digest) {
130
- data += `\ndigest: ${digest}`
131
- }
132
- const signer = crypto.createSign('sha256')
133
- signer.update(data)
134
- const signature = signer.sign(privateKey).toString('base64')
135
- signer.end()
136
- return `keyId="${keyId}",headers="(request-target) host date${(digest) ? ' digest' : ''}",signature="${signature.replace(/"/g, '\\"')}",algorithm="rsa-sha256"`
137
- }
138
-
139
- export const nockKeyRotate = async (username, domain = defaultDomain) =>
140
- domains.get(domain).set(username, await newKeyPair(username))
141
-
142
- export const makeActor = async (username, domain = defaultDomain) =>
143
- await as2.import({
144
- '@context': [
145
- 'https://www.w3.org/ns/activitystreams',
146
- 'https://w3id.org/security/v1'
147
- ],
148
- id: `https://${domain}/user/${username}`,
149
- type: 'Person',
150
- preferredUsername: username,
151
- inbox: `https://${domain}/user/${username}/inbox`,
152
- outbox: `https://${domain}/user/${username}/outbox`,
153
- followers: `https://${domain}/user/${username}/followers`,
154
- following: `https://${domain}/user/${username}/following`,
155
- liked: `https://${domain}/user/${username}/liked`,
156
- to: ['as:Public'],
157
- publicKey: {
158
- id: `https://${domain}/user/${username}/publickey`,
159
- type: 'CryptographicKey',
160
- owner: `https://${domain}/user/${username}`,
161
- publicKeyPem: await getPublicKey(username, domain)
162
- },
163
- url: {
164
- type: 'Link',
165
- href: `https://${domain}/profile/${username}`,
166
- mediaType: 'text/html'
167
- }
168
- })
169
-
170
- // Just the types we use here
171
- const isActivityType = (type) => ['Create', 'Update', 'Delete', 'Add', 'Remove', 'Follow', 'Accept', 'Reject', 'Like', 'Block', 'Flag', 'Undo'].includes(uppercase(type))
172
-
173
- export async function makeObject (
174
- username,
175
- type,
176
- num,
177
- domain = defaultDomain,
178
- extra = {}) {
179
- const props = {
180
- '@context': [
181
- 'https://www.w3.org/ns/activitystreams',
182
- 'https://purl.archive.org/socialweb/thread/1.0',
183
- { ostatus: 'http://ostatus.org/schema/1.0/' }
184
- ],
185
- id: nockFormat({ username, type, num, domain }),
186
- type: uppercase(type),
187
- to: 'as:Public',
188
- ...extra
189
- }
190
- if (isActivityType(type)) {
191
- props.actor = nockFormat({ username, domain })
192
- } else {
193
- props.attributedTo = nockFormat({ username, domain })
194
- props.replies = nockFormat({ username, type, num, domain, obj: 'replies' })
195
- props.shares = nockFormat({ username, type, num, domain, obj: 'shares' })
196
- props.likes = nockFormat({ username, type, num, domain, obj: 'likes' })
197
- props.thread = nockFormat({ username, type, num, domain, obj: 'thread' })
198
- props.context = nockFormat({ username, type, num, domain, obj: 'context' })
199
- props['ostatus:conversation'] = nockFormat({ username, type, num, domain, obj: 'conversation' })
200
- }
201
- return as2.import(props)
202
- }
203
-
204
- export const makeTransitive = (username, type, num, obj, domain = defaultDomain) =>
205
- as2.import({
206
- id: nockFormat({ username, type, num, obj, domain }),
207
- type: uppercase(type),
208
- to: 'as:Public',
209
- actor: nockFormat({ username, domain }),
210
- object: `https://${obj}`
211
- })
212
-
213
- const uppercase = (str) => str.charAt(0).toUpperCase() + str.slice(1)
214
- const lowercase = (str) => str.toLowerCase()
215
-
216
- export const postInbox = {}
217
-
218
- export const resetInbox = () => {
219
- for (const username in postInbox) {
220
- postInbox[username] = 0
221
- }
222
- }
223
-
224
- const requestHeaders = new Map()
225
-
226
- export function getRequestHeaders (uri) {
227
- return requestHeaders.get(uri)
228
- }
229
-
230
- export const resetRequestHeaders = () => {
231
- requestHeaders.clear()
232
- }
233
-
234
- const captureRequestHeaders = (domain, uri, req) => {
235
- const url = new URL(uri, `https://${domain}`).toString()
236
- const headers = req?.headers || {}
237
- requestHeaders.set(url, headers)
238
- }
239
-
240
- export const nockSetup = (domain, logger = null) =>
241
- nock(`https://${domain}`)
242
- .persist()
243
- .get(/^\/.well-known\/webfinger/)
244
- .reply(async function (uri, requestBody) {
245
- captureRequestHeaders(domain, uri, this?.req)
246
- const parsed = new URL(uri, `https://${domain}`)
247
- const resource = parsed.searchParams.get('resource')
248
- if (!resource) {
249
- return [400, 'Bad Request']
250
- }
251
- const username = resource.slice(5).split('@')[0]
252
- const webfinger = {
253
- subject: resource,
254
- links: [
255
- {
256
- rel: 'self',
257
- type: 'application/activity+json',
258
- href: `https://${domain}/user/${username}`
259
- }
260
- ]
261
- }
262
- return [200,
263
- JSON.stringify(webfinger),
264
- { 'Content-Type': 'application/jrd+json' }]
265
- })
266
- .get(/^\/user\/(\w+)$/)
267
- .reply(async function (uri, requestBody) {
268
- captureRequestHeaders(domain, uri, this?.req)
269
- const username = uri.match(/^\/user\/(\w+)$/)[1]
270
- const actor = await makeActor(username, domain)
271
- const actorText = await actor.write(
272
- { additional_context: 'https://w3id.org/security/v1' }
273
- )
274
- return [200, actorText, { 'Content-Type': 'application/activity+json' }]
275
- })
276
- .post(/^\/user\/(\w+)\/inbox$/)
277
- .reply(async function (uri, requestBody) {
278
- captureRequestHeaders(domain, uri, this?.req)
279
- const username = uri.match(/^\/user\/(\w+)\/inbox$/)[1]
280
- if (username in postInbox) {
281
- postInbox[username] += 1
282
- } else {
283
- postInbox[username] = 1
284
- }
285
- return [202, 'accepted']
286
- })
287
- .get(/^\/user\/(\w+)\/inbox$/)
288
- .reply(async function (uri, requestBody) {
289
- return [403, 'forbidden']
290
- })
291
- .get(/^\/user\/(\w+)\/publickey$/)
292
- .reply(async function (uri, requestBody) {
293
- captureRequestHeaders(domain, uri, this?.req)
294
- const username = uri.match(/^\/user\/(\w+)\/publickey$/)[1]
295
- const publicKey = await as2.import({
296
- '@context': [
297
- 'https://www.w3.org/ns/activitystreams',
298
- 'https://w3id.org/security/v1'
299
- ],
300
- id: `https://${domain}/user/${username}/publickey`,
301
- owner: `https://${domain}/user/${username}`,
302
- type: 'CryptographicKey',
303
- publicKeyPem: await getPublicKey(username, domain)
304
- })
305
- const publicKeyText = await publicKey.write(
306
- { additional_context: 'https://w3id.org/security/v1' }
307
- )
308
- return [200, publicKeyText, { 'Content-Type': 'application/activity+json' }]
309
- })
310
- .get(/^\/user\/(\w+)\/followers$/)
311
- .reply(async function (uri, requestBody) {
312
- captureRequestHeaders(domain, uri, this?.req)
313
- const username = uri.match(/^\/user\/(\w+)\/followers$/)[1]
314
- const items = ensureGraph(domain, username).get('followers')
315
- const followers = await as2.import({
316
- '@context': [
317
- 'https://www.w3.org/ns/activitystreams',
318
- 'https://w3id.org/fep/5711'
319
- ],
320
- id: `https://${domain}/user/${username}/followers`,
321
- attributedTo: `https://${domain}/user/${username}`,
322
- to: 'as:Public',
323
- followersOf: `https://${domain}/user/${username}`,
324
- type: 'OrderedCollection',
325
- totalItems: items.length,
326
- items
327
- })
328
- const followersText = await followers.write(
329
- { additional_context: 'https://w3id.org/fep/5711' }
330
- )
331
- return [200, followersText, { 'Content-Type': 'application/activity+json' }]
332
- })
333
- .get(/^\/user\/(\w+)\/following$/)
334
- .reply(async function (uri, requestBody) {
335
- captureRequestHeaders(domain, uri, this?.req)
336
- const username = uri.match(/^\/user\/(\w+)\/following$/)[1]
337
- const items = ensureGraph(domain, username).get('following')
338
- const following = await as2.import({
339
- '@context': [
340
- 'https://www.w3.org/ns/activitystreams',
341
- 'https://w3id.org/fep/5711'
342
- ],
343
- id: `https://${domain}/user/${username}/following`,
344
- attributedTo: `https://${domain}/user/${username}`,
345
- to: 'as:Public',
346
- followersOf: `https://${domain}/user/${username}`,
347
- type: 'OrderedCollection',
348
- totalItems: items.length,
349
- items
350
- })
351
- const followingText = await following.write(
352
- { additional_context: 'https://w3id.org/fep/5711' }
353
- )
354
- return [200, followingText, { 'Content-Type': 'application/activity+json' }]
355
- })
356
- .get(/^\/user\/(\w+)\/collection\/(\d+)$/)
357
- .reply(async function (uri, requestBody) {
358
- logger?.debug('Capturing request headers')
359
- captureRequestHeaders(domain, uri, this?.req)
360
- logger?.debug('Matching parameters')
361
- const match = uri.match(/^\/user\/(\w+)\/collection\/(\d+)$/)
362
- const username = match[1]
363
- const type = 'Collection'
364
- const num = parseInt(match[2])
365
- logger?.debug('Ensuring collection')
366
- const items = ensureCollection(domain, username, num)
367
- logger?.debug('Making collection object')
368
- const summary = `${num} collection by ${username}`
369
- const obj = await makeObject(username, type, num, domain, { items, summary })
370
- logger?.debug('Writing collection object to text')
371
- const objText = await obj.write({ useOriginalContext: true })
372
- logger?.debug('Sending output')
373
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
374
- })
375
- .get(/^\/user\/(\w+)\/orderedcollection\/(\d+)$/)
376
- .reply(async function (uri, requestBody) {
377
- captureRequestHeaders(domain, uri, this?.req)
378
- const match = uri.match(/^\/user\/(\w+)\/orderedcollection\/(\d+)$/)
379
- const username = match[1]
380
- const type = 'OrderedCollection'
381
- const num = parseInt(match[2])
382
- const orderedItems = ensureCollection(domain, username, num)
383
- const summary = `${num} ordered collection by ${username}`
384
- const obj = await makeObject(username, type, num, domain, { orderedItems, summary })
385
- const objText = await obj.write({ useOriginalContext: true })
386
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
387
- })
388
- .get(/^\/user\/(\w+)\/pagedcollection\/(\d+)$/)
389
- .reply(async function (uri, requestBody) {
390
- captureRequestHeaders(domain, uri, this?.req)
391
- const match = uri.match(/^\/user\/(\w+)\/pagedcollection\/(\d+)$/)
392
- const username = match[1]
393
- const type = 'Collection'
394
- const num = parseInt(match[2])
395
- const items = ensureCollection(domain, username, num)
396
- const totalItems = items.length
397
- const first = (totalItems > 0)
398
- ? nockFormat({ username, domain, type: 'PagedCollection', num, page: 0 })
399
- : undefined
400
- const obj = await makeObject(username, type, num, domain, { totalItems, first })
401
- const objText = await obj.write({ useOriginalContext: true })
402
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
403
- })
404
- .get(/^\/user\/(\w+)\/pagedcollection\/(\d+)\/page\/(\d+)$/)
405
- .reply(async function (uri, requestBody) {
406
- captureRequestHeaders(domain, uri, this?.req)
407
- const match = uri.match(/^\/user\/(\w+)\/pagedcollection\/(\d+)\/page\/(\d+)$/)
408
- const username = match[1]
409
- const type = 'CollectionPage'
410
- const num = parseInt(match[2])
411
- const page = parseInt(match[3])
412
- const allItems = ensureCollection(domain, username, num)
413
- const items = allItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
414
- const next = (allItems.length > (page + 1) * PAGE_SIZE)
415
- ? nockFormat({ username, domain, type: 'PagedCollection', num, page: page + 1 })
416
- : undefined
417
- const obj = await makeObject(username, type, num, domain, { items, next })
418
- const objText = await obj.write({ useOriginalContext: true })
419
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
420
- })
421
- .get(/^\/user\/(\w+)\/pagedorderedcollection\/(\d+)$/)
422
- .reply(async function (uri, requestBody) {
423
- captureRequestHeaders(domain, uri, this?.req)
424
- const match = uri.match(/^\/user\/(\w+)\/pagedorderedcollection\/(\d+)$/)
425
- const username = match[1]
426
- const type = 'OrderedCollection'
427
- const num = parseInt(match[2])
428
- const items = ensureCollection(domain, username, num)
429
- const totalItems = items.length
430
- const first = (totalItems > 0)
431
- ? nockFormat({ username, domain, type: 'PagedOrderedCollection', num, page: 0 })
432
- : undefined
433
- const obj = await makeObject(username, type, num, domain, { totalItems, first })
434
- const objText = await obj.write({ useOriginalContext: true })
435
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
436
- })
437
- .get(/^\/user\/(\w+)\/pagedorderedcollection\/(\d+)\/page\/(\d+)$/)
438
- .reply(async function (uri, requestBody) {
439
- captureRequestHeaders(domain, uri, this?.req)
440
- const match = uri.match(/^\/user\/(\w+)\/pagedorderedcollection\/(\d+)\/page\/(\d+)$/)
441
- const username = match[1]
442
- const type = 'OrderedCollectionPage'
443
- const num = parseInt(match[2])
444
- const page = parseInt(match[3])
445
- const allItems = ensureCollection(domain, username, num)
446
- const orderedItems = allItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
447
- const next = (allItems.length > (page + 1) * PAGE_SIZE)
448
- ? nockFormat({ username, domain, type: 'PagedOrderedCollection', num, page: page + 1 })
449
- : undefined
450
- const obj = await makeObject(username, type, num, domain, { orderedItems, next })
451
- const objText = await obj.write({ useOriginalContext: true })
452
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
453
- })
454
- .get(/^\/user\/(\w+)\/(\w+)\/(\d+)$/)
455
- .reply(async function (uri, requestBody) {
456
- captureRequestHeaders(domain, uri, this?.req)
457
- const match = uri.match(/^\/user\/(\w+)\/(\w+)\/(\d+)$/)
458
- const username = match[1]
459
- const type = uppercase(match[2])
460
- const num = match[3]
461
- const obj = await makeObject(username, type, num, domain)
462
- const objText = await obj.write({ useOriginalContext: true })
463
- return [200, objText, { 'Content-Type': 'application/activity+json' }]
464
- })
465
- .get(/^\/user\/(\w+)\/(\w+)\/(\d+)\/(.*)$/)
466
- .reply(async function (uri, requestBody) {
467
- captureRequestHeaders(domain, uri, this?.req)
468
- const match = uri.match(/^\/user\/(\w+)\/(\w+)\/(\d+)\/(.*)$/)
469
- const username = match[1]
470
- const type = match[2]
471
- const num = match[3]
472
- const obj = match[4]
473
- const act = await makeTransitive(username, type, num, obj, domain)
474
- const actText = await act.write()
475
- return [200, actText, { 'Content-Type': 'application/activity+json' }]
476
- })
477
-
478
- export function nockFormat ({ username, type, num, obj, key, collection, page, domain = defaultDomain }) {
479
- let url = `https://${domain}/user/${username}`
480
- if (key) {
481
- url = `${url}/publickey`
482
- } else if (collection) {
483
- url = `${url}/${collection}`
484
- } else {
485
- if (type && num) {
486
- url = `${url}/${lowercase(type)}/${num}`
487
- if (obj) {
488
- if (obj.startsWith('https://')) {
489
- url = `${url}/${obj.slice(8)}`
490
- } else {
491
- url = `${url}/${obj}`
492
- }
493
- } else if (typeof page === 'number') {
494
- url = `${url}/page/${page}`
495
- }
496
- }
497
- }
498
- return url
499
- }