@evanp/activitypub-bot 0.8.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.
Files changed (67) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/main-docker.yml +45 -0
  3. package/.github/workflows/tag-docker.yml +54 -0
  4. package/Dockerfile +23 -0
  5. package/LICENSE +661 -0
  6. package/README.md +82 -0
  7. package/bots/index.js +7 -0
  8. package/docs/activitypub.bot.drawio +110 -0
  9. package/index.js +23 -0
  10. package/lib/activitydistributor.js +263 -0
  11. package/lib/activityhandler.js +999 -0
  12. package/lib/activitypubclient.js +126 -0
  13. package/lib/activitystreams.js +41 -0
  14. package/lib/actorstorage.js +300 -0
  15. package/lib/app.js +173 -0
  16. package/lib/authorizer.js +133 -0
  17. package/lib/bot.js +44 -0
  18. package/lib/botcontext.js +520 -0
  19. package/lib/botdatastorage.js +87 -0
  20. package/lib/bots/donothing.js +11 -0
  21. package/lib/bots/ok.js +41 -0
  22. package/lib/digester.js +23 -0
  23. package/lib/httpsignature.js +195 -0
  24. package/lib/httpsignatureauthenticator.js +81 -0
  25. package/lib/keystorage.js +113 -0
  26. package/lib/microsyntax.js +140 -0
  27. package/lib/objectcache.js +48 -0
  28. package/lib/objectstorage.js +319 -0
  29. package/lib/remotekeystorage.js +116 -0
  30. package/lib/routes/collection.js +92 -0
  31. package/lib/routes/health.js +24 -0
  32. package/lib/routes/inbox.js +83 -0
  33. package/lib/routes/object.js +69 -0
  34. package/lib/routes/server.js +47 -0
  35. package/lib/routes/user.js +63 -0
  36. package/lib/routes/webfinger.js +36 -0
  37. package/lib/urlformatter.js +97 -0
  38. package/package.json +51 -0
  39. package/tests/activitydistributor.test.js +606 -0
  40. package/tests/activityhandler.test.js +2185 -0
  41. package/tests/activitypubclient.test.js +225 -0
  42. package/tests/actorstorage.test.js +261 -0
  43. package/tests/app.test.js +17 -0
  44. package/tests/authorizer.test.js +306 -0
  45. package/tests/bot.donothing.test.js +30 -0
  46. package/tests/bot.ok.test.js +101 -0
  47. package/tests/botcontext.test.js +674 -0
  48. package/tests/botdatastorage.test.js +87 -0
  49. package/tests/digester.test.js +56 -0
  50. package/tests/fixtures/bots.js +15 -0
  51. package/tests/httpsignature.test.js +200 -0
  52. package/tests/httpsignatureauthenticator.test.js +463 -0
  53. package/tests/keystorage.test.js +89 -0
  54. package/tests/microsyntax.test.js +122 -0
  55. package/tests/objectcache.test.js +133 -0
  56. package/tests/objectstorage.test.js +148 -0
  57. package/tests/remotekeystorage.test.js +76 -0
  58. package/tests/routes.actor.test.js +207 -0
  59. package/tests/routes.collection.test.js +434 -0
  60. package/tests/routes.health.test.js +41 -0
  61. package/tests/routes.inbox.test.js +135 -0
  62. package/tests/routes.object.test.js +519 -0
  63. package/tests/routes.server.test.js +69 -0
  64. package/tests/routes.webfinger.test.js +41 -0
  65. package/tests/urlformatter.test.js +164 -0
  66. package/tests/utils/digest.js +7 -0
  67. package/tests/utils/nock.js +276 -0
@@ -0,0 +1,306 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import { Authorizer } from '../lib/authorizer.js'
3
+ import { ActorStorage } from '../lib/actorstorage.js'
4
+ import { Sequelize } from 'sequelize'
5
+ import { UrlFormatter } from '../lib/urlformatter.js'
6
+ import { ObjectStorage } from '../lib/objectstorage.js'
7
+ import { KeyStorage } from '../lib/keystorage.js'
8
+ import { ActivityPubClient } from '../lib/activitypubclient.js'
9
+ import as2 from '../lib/activitystreams.js'
10
+ import assert from 'node:assert/strict'
11
+ import { nanoid } from 'nanoid'
12
+ import { HTTPSignature } from '../lib/httpsignature.js'
13
+ import Logger from 'pino'
14
+ import { Digester } from '../lib/digester.js'
15
+
16
+ describe('Authorizer', () => {
17
+ let authorizer = null
18
+ let actorStorage = null
19
+ let formatter = null
20
+ let connection = null
21
+ let objectStorage = null
22
+ let keyStorage = null
23
+ let client = null
24
+
25
+ let actor1 = null
26
+ let actor2 = null
27
+ let actor3 = null
28
+ let publicObject = null
29
+ let followersOnlyObject = null
30
+ let privateObject = null
31
+ let remoteUnconnected = null
32
+ let remoteFollower = null
33
+ let remoteAddressee = null
34
+ let remotePublicObject = null
35
+ let remotePrivateObject = null
36
+
37
+ before(async () => {
38
+ const logger = Logger({
39
+ level: 'silent'
40
+ })
41
+ formatter = new UrlFormatter('https://activitypubbot.example')
42
+ connection = new Sequelize('sqlite::memory:', { logging: false })
43
+ await connection.authenticate()
44
+ actorStorage = new ActorStorage(connection, formatter)
45
+ await actorStorage.initialize()
46
+ objectStorage = new ObjectStorage(connection)
47
+ await objectStorage.initialize()
48
+ keyStorage = new KeyStorage(connection, logger)
49
+ await keyStorage.initialize()
50
+ const signer = new HTTPSignature(logger)
51
+ const digester = new Digester(logger)
52
+ client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
53
+ actor1 = await actorStorage.getActor('test1')
54
+ actor2 = await actorStorage.getActor('test2')
55
+ await actorStorage.addToCollection(
56
+ 'test1',
57
+ 'followers',
58
+ actor2
59
+ )
60
+ actor3 = await actorStorage.getActor('test3')
61
+ remoteUnconnected = await as2.import({
62
+ id: 'https://remote.example/user/remote1',
63
+ type: 'Person',
64
+ preferredUsername: 'remote1',
65
+ to: 'as:Public'
66
+ })
67
+ remoteFollower = await as2.import({
68
+ id: 'https://remote.example/user/remote2',
69
+ type: 'Person',
70
+ preferredUsername: 'remote2',
71
+ to: 'as:Public'
72
+ })
73
+ await actorStorage.addToCollection(
74
+ 'test1',
75
+ 'followers',
76
+ remoteFollower
77
+ )
78
+ remoteAddressee = await as2.import({
79
+ id: 'https://remote.example/user/remote3',
80
+ type: 'Person',
81
+ preferredUsername: 'remote3',
82
+ to: 'as:Public'
83
+ })
84
+ publicObject = await as2.import({
85
+ id: formatter.format({
86
+ username: 'test1',
87
+ type: 'object',
88
+ nanoid: nanoid()
89
+ }),
90
+ type: 'Object',
91
+ attributedTo: actor1.id,
92
+ to: 'as:Public'
93
+ })
94
+ followersOnlyObject = await as2.import({
95
+ id: formatter.format({
96
+ username: 'test1',
97
+ type: 'object',
98
+ nanoid: nanoid()
99
+ }),
100
+ type: 'Object',
101
+ attributedTo: actor1.id,
102
+ to: formatter.format({
103
+ username: 'test1',
104
+ collection: 'followers'
105
+ })
106
+ })
107
+ privateObject = await as2.import({
108
+ id: formatter.format({
109
+ username: 'test1',
110
+ type: 'object',
111
+ nanoid: nanoid()
112
+ }),
113
+ type: 'Object',
114
+ attributedTo: actor1.id,
115
+ to: [actor2.id, remoteAddressee.id]
116
+ })
117
+ remotePublicObject = await as2.import({
118
+ id: 'https://remote.example/user/remote1/object/1',
119
+ type: 'Object',
120
+ attributedTo: remoteUnconnected.id,
121
+ to: 'as:Public'
122
+ })
123
+ remotePrivateObject = await as2.import({
124
+ id: 'https://remote.example/user/remote1/object/2',
125
+ type: 'Object',
126
+ attributedTo: remoteUnconnected.id,
127
+ to: actor2.id
128
+ })
129
+ })
130
+
131
+ after(async () => {
132
+ await connection.close()
133
+ formatter = null
134
+ actorStorage = null
135
+ connection = null
136
+ authorizer = null
137
+ objectStorage = null
138
+ })
139
+
140
+ it('should be a class', async () => {
141
+ assert.strictEqual(typeof Authorizer, 'function')
142
+ })
143
+
144
+ it('can be instantiated', async () => {
145
+ try {
146
+ authorizer = new Authorizer(actorStorage, formatter, client)
147
+ assert.strictEqual(typeof authorizer, 'object')
148
+ } catch (error) {
149
+ assert.fail(error)
150
+ }
151
+ })
152
+
153
+ it('can check the creator can read a public local object', async () => {
154
+ assert.strictEqual(true, await authorizer.canRead(actor1, publicObject))
155
+ })
156
+
157
+ it('can check the creator can read a followers-only local object', async () => {
158
+ assert.strictEqual(
159
+ true,
160
+ await authorizer.canRead(actor1, followersOnlyObject)
161
+ )
162
+ })
163
+
164
+ it('can check the creator can read a private local object', async () => {
165
+ assert.strictEqual(
166
+ true,
167
+ await authorizer.canRead(actor1, privateObject)
168
+ )
169
+ })
170
+
171
+ it('can check if a local follower can read a public local object', async () => {
172
+ assert.strictEqual(true, await authorizer.canRead(actor2, publicObject))
173
+ })
174
+
175
+ it('can check if a local follower can read a followers-only local object', async () => {
176
+ assert.strictEqual(true, await authorizer.canRead(actor2, followersOnlyObject))
177
+ })
178
+
179
+ it('can check if a local addressee can read a private local object', async () => {
180
+ assert.strictEqual(true, await authorizer.canRead(actor2, privateObject))
181
+ })
182
+
183
+ it('can check if a local non-follower can read a public local object', async () => {
184
+ assert.strictEqual(true, await authorizer.canRead(actor3, publicObject))
185
+ })
186
+
187
+ it('can check if a local non-follower can read a followers-only local object', async () => {
188
+ assert.strictEqual(false, await authorizer.canRead(actor3, followersOnlyObject))
189
+ })
190
+
191
+ it('can check if a local non-addressee can read a private local object', async () => {
192
+ assert.strictEqual(false, await authorizer.canRead(actor3, privateObject))
193
+ })
194
+
195
+ it('can check if the null actor can read a public local object', async () => {
196
+ assert.strictEqual(true, await authorizer.canRead(null, publicObject))
197
+ })
198
+
199
+ it('can check if the null actor can read a followers-only local object', async () => {
200
+ assert.strictEqual(false, await authorizer.canRead(null, followersOnlyObject))
201
+ })
202
+
203
+ it('can check if the null actor can read a private local object', async () => {
204
+ assert.strictEqual(false, await authorizer.canRead(null, privateObject))
205
+ })
206
+
207
+ it('can check that an unconnected remote actor can read a public local object', async () => {
208
+ assert.strictEqual(true, await authorizer.canRead(remoteUnconnected, publicObject))
209
+ })
210
+
211
+ it('can check that an unconnected remote actor cannot read a followers-only local object', async () => {
212
+ assert.strictEqual(
213
+ false,
214
+ await authorizer.canRead(remoteUnconnected, followersOnlyObject)
215
+ )
216
+ })
217
+
218
+ it('can check that an unconnected remote actor cannot read a private local object', async () => {
219
+ assert.strictEqual(
220
+ false,
221
+ await authorizer.canRead(remoteUnconnected, privateObject)
222
+ )
223
+ })
224
+
225
+ it('can check that a remote follower can read a public local object', async () => {
226
+ assert.strictEqual(true, await authorizer.canRead(remoteFollower, publicObject))
227
+ })
228
+
229
+ it('can check that a remote follower can read a followers-only local object', async () => {
230
+ assert.strictEqual(
231
+ true,
232
+ await authorizer.canRead(remoteFollower, followersOnlyObject)
233
+ )
234
+ })
235
+
236
+ it('can check that a remote follower cannot read a private local object', async () => {
237
+ assert.strictEqual(
238
+ false,
239
+ await authorizer.canRead(remoteFollower, privateObject)
240
+ )
241
+ })
242
+
243
+ it('can check that a remote addressee can read a private local object', async () => {
244
+ assert.strictEqual(true, await authorizer.canRead(remoteAddressee, privateObject))
245
+ })
246
+
247
+ it('can check that a local actor can read a public remote object', async () => {
248
+ assert.strictEqual(true, await authorizer.canRead(actor1, remotePublicObject))
249
+ })
250
+
251
+ it('can check that a local non-addressee cannot read a private remote object', async () => {
252
+ assert.strictEqual(null, await authorizer.canRead(actor1, remotePrivateObject))
253
+ })
254
+
255
+ it('can check that a local addressee can read a private remote object', async () => {
256
+ assert.strictEqual(true, await authorizer.canRead(actor2, remotePrivateObject))
257
+ })
258
+
259
+ it('can check that two objects have the same origin', async () => {
260
+ const object1 = await as2.import({
261
+ id: 'https://example.com/object/1',
262
+ type: 'Object'
263
+ })
264
+ const object2 = await as2.import({
265
+ id: 'https://example.com/object/2',
266
+ type: 'Object'
267
+ })
268
+ assert.strictEqual(true, await authorizer.sameOrigin(object1, object2))
269
+ })
270
+
271
+ it('can check that two objects have different origins', async () => {
272
+ const object1 = await as2.import({
273
+ id: 'https://example.com/object/1',
274
+ type: 'Object'
275
+ })
276
+ const object2 = await as2.import({
277
+ id: 'https://other.example/object/2',
278
+ type: 'Object'
279
+ })
280
+ assert.strictEqual(false, await authorizer.sameOrigin(object1, object2))
281
+ })
282
+
283
+ it('can check that two objects have different origins by port', async () => {
284
+ const object1 = await as2.import({
285
+ id: 'https://example.com/object/1',
286
+ type: 'Object'
287
+ })
288
+ const object2 = await as2.import({
289
+ id: 'https://example.com:8000/object/2',
290
+ type: 'Object'
291
+ })
292
+ assert.strictEqual(false, await authorizer.sameOrigin(object1, object2))
293
+ })
294
+
295
+ it('can check that two objects have different origins by protocol', async () => {
296
+ const object1 = await as2.import({
297
+ id: 'https://example.com/object/1',
298
+ type: 'Object'
299
+ })
300
+ const object2 = await as2.import({
301
+ id: 'http://example.com/object/2',
302
+ type: 'Object'
303
+ })
304
+ assert.strictEqual(false, await authorizer.sameOrigin(object1, object2))
305
+ })
306
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, it, before } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import request from 'supertest'
4
+
5
+ import { makeApp } from '../lib/app.js'
6
+
7
+ import { nockSetup } from './utils/nock.js'
8
+ import bots from './fixtures/bots.js'
9
+
10
+ describe('DoNothing bot', async () => {
11
+ const host = 'activitypubbot.example'
12
+ const origin = `https://${host}`
13
+ const databaseUrl = 'sqlite::memory:'
14
+ let app = null
15
+
16
+ before(async () => {
17
+ nockSetup('social.example')
18
+ app = await makeApp(databaseUrl, origin, bots, 'silent')
19
+ })
20
+
21
+ describe('Bot exists', async () => {
22
+ let response = null
23
+ it('should work without an error', async () => {
24
+ response = await request(app).get('/user/null')
25
+ })
26
+ it('should return 200 OK', async () => {
27
+ assert.strictEqual(response.status, 200)
28
+ })
29
+ })
30
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, it, before } from 'node:test'
2
+ import assert from 'node:assert'
3
+ import as2 from '../lib/activitystreams.js'
4
+ import request from 'supertest'
5
+
6
+ import { makeApp } from '../lib/app.js'
7
+
8
+ import { nockSetup, nockSignature, nockFormat, postInbox } from './utils/nock.js'
9
+ import { makeDigest } from './utils/digest.js'
10
+ import bots from './fixtures/bots.js'
11
+
12
+ describe('OK bot', async () => {
13
+ const host = 'activitypubbot.example'
14
+ const origin = `https://${host}`
15
+ const databaseUrl = 'sqlite::memory:'
16
+ let app = null
17
+
18
+ before(async () => {
19
+ nockSetup('social.example')
20
+ app = await makeApp(databaseUrl, origin, bots, 'silent')
21
+ })
22
+
23
+ describe('responds to a mention', async () => {
24
+ const username = 'actor2'
25
+ const path = '/user/ok/inbox'
26
+ const url = `${origin}${path}`
27
+ const date = new Date().toUTCString()
28
+ const activity = await as2.import({
29
+ type: 'Create',
30
+ actor: nockFormat({ username }),
31
+ id: nockFormat({ username, type: 'create', num: 1 }),
32
+ object: {
33
+ id: nockFormat({ username, type: 'note', num: 1 }),
34
+ type: 'Note',
35
+ source: 'Hello, @ok!',
36
+ content: `Hello, @<a href="${origin}/user/ok">ok</a>!`,
37
+ to: `${origin}/user/ok`,
38
+ cc: 'as:Public',
39
+ attributedTo: nockFormat({ username }),
40
+ tag: [
41
+ {
42
+ type: 'Mention',
43
+ href: `${origin}/user/ok`,
44
+ name: `@ok@${host}`
45
+ }
46
+ ]
47
+ },
48
+ to: `${origin}/user/ok`,
49
+ cc: 'as:Public'
50
+ })
51
+ const body = await activity.write()
52
+ const digest = makeDigest(body)
53
+ const signature = await nockSignature({
54
+ method: 'POST',
55
+ username,
56
+ url,
57
+ digest,
58
+ date
59
+ })
60
+ let response = null
61
+ it('should work without an error', async () => {
62
+ response = await request(app)
63
+ .post(path)
64
+ .send(body)
65
+ .set('Signature', signature)
66
+ .set('Date', date)
67
+ .set('Host', host)
68
+ .set('Digest', digest)
69
+ .set('Content-Type', 'application/activity+json')
70
+ assert.ok(response)
71
+ await app.onIdle()
72
+ })
73
+ it('should return a 200 status', async () => {
74
+ assert.strictEqual(response.status, 200, JSON.stringify(response.body))
75
+ })
76
+ it('should deliver the reply to the mentioned actor', async () => {
77
+ assert.strictEqual(postInbox.actor2, 1)
78
+ })
79
+ let reply = null
80
+ let note = null
81
+ it('should have the reply in its outbox', async () => {
82
+ const { actorStorage, objectStorage } = app.locals
83
+ const outbox = await actorStorage.getCollection('ok', 'outbox')
84
+ assert.strictEqual(outbox.totalItems, 1)
85
+ const outboxPage = await actorStorage.getCollectionPage('ok', 'outbox', 1)
86
+ assert.strictEqual(outboxPage.items.length, 1)
87
+ const arry = Array.from(outboxPage.items)
88
+ reply = await objectStorage.read(arry[0].id)
89
+ assert.ok(reply)
90
+ const objects = Array.from(reply.object)
91
+ note = await objectStorage.read(objects[0].id)
92
+ assert.ok(note)
93
+ })
94
+ it('should have the inReplyTo property', async () => {
95
+ assert.strictEqual(
96
+ Array.from(note.inReplyTo)[0].id,
97
+ Array.from(activity.object)[0].id
98
+ )
99
+ })
100
+ })
101
+ })