@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,126 @@
1
+ import as2 from './activitystreams.js'
2
+ import fetch from 'node-fetch'
3
+ import assert from 'node:assert'
4
+ import createHttpError from 'http-errors'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = path.dirname(__filename)
11
+
12
+ const { version } = JSON.parse(
13
+ fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
14
+ )
15
+
16
+ export class ActivityPubClient {
17
+ static #githubUrl = 'https://github.com/evanp/activitypub-bot'
18
+ static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
19
+ static #accept =
20
+ ['application/activity+json',
21
+ 'application/ld+json',
22
+ 'application/json'].join(',')
23
+
24
+ #keyStorage = null
25
+ #urlFormatter = null
26
+ #signer = null
27
+ #digester = null
28
+ #logger = null
29
+
30
+ constructor (keyStorage, urlFormatter, signer, digester, logger) {
31
+ this.#keyStorage = keyStorage
32
+ this.#urlFormatter = urlFormatter
33
+ this.#signer = signer
34
+ this.#digester = digester
35
+ this.#logger = logger.child({ class: this.constructor.name })
36
+ }
37
+
38
+ async get (url, username = null) {
39
+ assert.ok(url)
40
+ assert.equal(typeof url, 'string')
41
+ const res = await this.#getRes(url, username, true)
42
+ return await this.#handleRes(res, url)
43
+ }
44
+
45
+ async getKey (url) {
46
+ assert.ok(url)
47
+ assert.equal(typeof url, 'string')
48
+ let res = await this.#getRes(url, null, false)
49
+ if ([401, 403, 404].includes(res.status)) {
50
+ // If we get a 401, 403, or 404, we should try again with the key
51
+ res = await this.#getRes(url, null, true)
52
+ }
53
+ return await this.#handleRes(res, url)
54
+ }
55
+
56
+ async #getRes (url, username = null, sign = false) {
57
+ assert.ok(url)
58
+ assert.equal(typeof url, 'string')
59
+ const date = new Date().toUTCString()
60
+ const headers = {
61
+ accept: ActivityPubClient.#accept,
62
+ date,
63
+ 'user-agent': ActivityPubClient.#userAgent
64
+ }
65
+ const method = 'GET'
66
+ if (sign) {
67
+ headers.signature =
68
+ await this.#sign({ username, url, method, headers })
69
+ }
70
+ return await fetch(url,
71
+ {
72
+ method,
73
+ headers
74
+ }
75
+ )
76
+ }
77
+
78
+ async #handleRes (res, url) {
79
+ if (res.status < 200 || res.status > 299) {
80
+ throw createHttpError(res.status, `Could not fetch ${url}`)
81
+ }
82
+ const json = await res.json()
83
+ const obj = await as2.import(json)
84
+ return obj
85
+ }
86
+
87
+ async post (url, obj, username) {
88
+ assert.ok(url)
89
+ assert.equal(typeof url, 'string')
90
+ assert.ok(obj)
91
+ assert.equal(typeof obj, 'object')
92
+ assert.ok(username)
93
+ assert.equal(typeof username, 'string')
94
+ const body = await obj.write()
95
+ const headers = {
96
+ date: new Date().toUTCString(),
97
+ 'user-agent': ActivityPubClient.#userAgent,
98
+ 'content-type': 'application/activity+json',
99
+ digest: await this.#digester.digest(body)
100
+ }
101
+ const method = 'POST'
102
+ assert.ok(headers)
103
+ headers.signature = await this.#sign({ username, url, method, headers })
104
+ const res = await fetch(url,
105
+ {
106
+ method,
107
+ headers,
108
+ body
109
+ }
110
+ )
111
+ if (res.status < 200 || res.status > 299) {
112
+ throw createHttpError(res.status, await res.text())
113
+ }
114
+ }
115
+
116
+ async #sign ({ username, url, method, headers }) {
117
+ assert.ok(url)
118
+ assert.ok(method)
119
+ assert.ok(headers)
120
+ const privateKey = await this.#keyStorage.getPrivateKey(username)
121
+ const keyId = (username)
122
+ ? this.#urlFormatter.format({ username, type: 'publickey' })
123
+ : this.#urlFormatter.format({ server: true, type: 'publickey' })
124
+ return this.#signer.sign({ privateKey, keyId, url, method, headers })
125
+ }
126
+ }
@@ -0,0 +1,41 @@
1
+ import as2 from 'activitystrea.ms'
2
+
3
+ as2.registerContext('https://w3id.org/fep/5711', {
4
+ '@context': {
5
+ inv: 'https://w3id.org/fep/5711#',
6
+ likesOf: {
7
+ '@id': 'inv:likesOf',
8
+ '@type': '@id'
9
+ },
10
+ sharesOf: {
11
+ '@id': 'inv:sharesOf',
12
+ '@type': '@id'
13
+ },
14
+ repliesOf: {
15
+ '@id': 'inv:repliesOf',
16
+ '@type': '@id'
17
+ },
18
+ inboxOf: {
19
+ '@id': 'inv:inboxOf',
20
+ '@type': '@id'
21
+ },
22
+ outboxOf: {
23
+ '@id': 'inv:outboxOf',
24
+ '@type': '@id'
25
+ },
26
+ followersOf: {
27
+ '@id': 'inv:followersOf',
28
+ '@type': '@id'
29
+ },
30
+ followingOf: {
31
+ '@id': 'inv:followingOf',
32
+ '@type': '@id'
33
+ },
34
+ likedOf: {
35
+ '@id': 'inv:likedOf',
36
+ '@type': '@id'
37
+ }
38
+ }
39
+ })
40
+
41
+ export default as2
@@ -0,0 +1,300 @@
1
+ import as2 from './activitystreams.js'
2
+ import assert from 'node:assert'
3
+
4
+ export class ActorStorage {
5
+ #connection = null
6
+ #formatter = null
7
+ static #MAX_ITEMS_PER_PAGE = 20
8
+ constructor (connection, formatter) {
9
+ this.#connection = connection
10
+ this.#formatter = formatter
11
+ }
12
+
13
+ async initialize () {
14
+ await this.#connection.query(`
15
+ CREATE TABLE IF NOT EXISTS actorcollection (
16
+ username varchar(512) NOT NULL,
17
+ property varchar(512) NOT NULL,
18
+ first INTEGER NOT NULL,
19
+ totalItems INTEGER NOT NULL DEFAULT 0,
20
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
+ PRIMARY KEY (username, property)
23
+ );`
24
+ )
25
+ await this.#connection.query(`
26
+ CREATE TABLE IF NOT EXISTS actorcollectionpage (
27
+ username varchar(512) NOT NULL,
28
+ property varchar(512) NOT NULL,
29
+ item varchar(512) NOT NULL,
30
+ page INTEGER NOT NULL,
31
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
32
+ PRIMARY KEY (username, property, item)
33
+ );`
34
+ )
35
+ await this.#connection.query(
36
+ `CREATE INDEX IF NOT EXISTS actorcollectionpage_username_property_page
37
+ ON actorcollectionpage (username, property, page);`
38
+ )
39
+ }
40
+
41
+ async getActor (username, props = {}) {
42
+ assert.ok(username)
43
+ assert.equal(typeof username, 'string')
44
+ const type = ('type' in props)
45
+ ? (Array.isArray(props.type))
46
+ ? [...props.type, 'Service']
47
+ : [props.type, 'Service']
48
+ : ['Service']
49
+ // XXX: spread props first so type is not overwritten
50
+ return await as2.import({
51
+ ...props,
52
+ id: this.#formatter.format({ username }),
53
+ type,
54
+ preferredUsername: username,
55
+ inbox: this.#formatter.format({ username, collection: 'inbox' }),
56
+ outbox: this.#formatter.format({ username, collection: 'outbox' }),
57
+ followers: this.#formatter.format({ username, collection: 'followers' }),
58
+ following: this.#formatter.format({ username, collection: 'following' }),
59
+ liked: this.#formatter.format({ username, collection: 'liked' }),
60
+ to: 'as:Public'
61
+ })
62
+ }
63
+
64
+ async getActorById (id) {
65
+ assert.ok(id)
66
+ assert.equal(typeof id, 'string')
67
+ const username = this.#formatter.getUserName(id)
68
+ return await this.getActor(username)
69
+ }
70
+
71
+ async getCollection (username, property) {
72
+ assert.ok(username)
73
+ assert.equal(typeof username, 'string')
74
+ assert.ok(property)
75
+ assert.equal(typeof property, 'string')
76
+ const [totalItems, first, createdAt, updatedAt] =
77
+ await this.#getCollectionInfo(
78
+ username,
79
+ property
80
+ )
81
+ return await as2.import({
82
+ '@context': [
83
+ 'https://www.w3.org/ns/activitystreams',
84
+ 'https://w3id.org/fep/5711'
85
+ ],
86
+ id: this.#formatter.format({ username, collection: property }),
87
+ type: 'OrderedCollection',
88
+ attributedTo: this.#formatter.format({ username }),
89
+ [`${property}Of`]: this.#formatter.format({ username }),
90
+ to: 'as:Public',
91
+ summaryMap: {
92
+ en: 'The ' + property + ' collection for user ' + username
93
+ },
94
+ totalItems,
95
+ first: this.#formatter.format({
96
+ username,
97
+ collection: property,
98
+ page: first
99
+ }),
100
+ last: this.#formatter.format({ username, collection: property, page: 1 }),
101
+ published: createdAt,
102
+ updated: updatedAt
103
+ })
104
+ }
105
+
106
+ async getCollectionPage (username, property, page) {
107
+ assert.ok(username)
108
+ assert.equal(typeof username, 'string')
109
+ assert.ok(property)
110
+ assert.equal(typeof property, 'string')
111
+ assert.ok(page)
112
+ assert.equal(typeof page, 'number')
113
+ assert.ok(page > 0)
114
+ const [, first] = await this.#getCollectionInfo(username, property)
115
+ if (page > first) {
116
+ throw new Error('page out of range')
117
+ }
118
+ const result = await this.#connection.query(
119
+ `SELECT item
120
+ FROM actorcollectionpage
121
+ WHERE username = ? AND property = ? AND page = ?
122
+ ORDER BY createdAt DESC, item DESC
123
+ ;`,
124
+ { replacements: [username, property, page] })
125
+ const items = []
126
+ for (const row of result[0]) {
127
+ items.push(row.item)
128
+ }
129
+ return await as2.import({
130
+ id: this.#formatter.format({ username, collection: property, page }),
131
+ type: 'OrderedCollectionPage',
132
+ partOf: this.#formatter.format({ username, collection: property }),
133
+ attributedTo: this.#formatter.format({ username }),
134
+ to: 'as:Public',
135
+ summaryMap: {
136
+ en: `Page ${page} of the ${property} collection for user ${username}`
137
+ },
138
+ next: (page === 1)
139
+ ? null
140
+ : this.#formatter.format({ username, collection: property, page: page - 1 }),
141
+ prev: (page === first)
142
+ ? null
143
+ : this.#formatter.format({ username, collection: property, page: page + 1 }),
144
+ items
145
+ })
146
+ }
147
+
148
+ async addToCollection (username, property, object) {
149
+ assert.ok(this.#connection, 'ActorStorage not initialized')
150
+ assert.ok(username, 'username is required')
151
+ assert.equal(typeof username, 'string', 'username must be a string')
152
+ assert.ok(property, 'property is required')
153
+ assert.equal(typeof property, 'string', 'property must be a string')
154
+ assert.ok(object, 'object is required')
155
+ assert.equal(typeof object, 'object', 'object must be an object')
156
+
157
+ const [, first, createdAt] = await this.#getCollectionInfo(
158
+ username,
159
+ property
160
+ )
161
+
162
+ if (createdAt === null) {
163
+ await this.#connection.query(
164
+ `INSERT INTO actorcollection (username, property, first, totalItems)
165
+ VALUES (?, ?, 1, 0)`,
166
+ { replacements: [username, property] }
167
+ )
168
+ }
169
+
170
+ const count = await this.#itemCount(username, property, first)
171
+
172
+ const page = (count >= ActorStorage.#MAX_ITEMS_PER_PAGE)
173
+ ? first + 1
174
+ : first
175
+
176
+ if (page > first) {
177
+ await this.#connection.query(
178
+ `UPDATE actorcollection
179
+ SET first = ?, updatedAt = CURRENT_TIMESTAMP
180
+ WHERE username = ? AND property = ?`,
181
+ { replacements: [page, username, property] }
182
+ )
183
+ }
184
+
185
+ await this.#connection.query(
186
+ `INSERT INTO actorcollectionpage (username, property, item, page)
187
+ VALUES (?, ?, ?, ?)`,
188
+ { replacements: [username, property, object.id, page] }
189
+ )
190
+
191
+ await this.#connection.query(
192
+ `UPDATE actorcollection
193
+ SET totalItems = totalItems + 1, updatedAt = CURRENT_TIMESTAMP
194
+ WHERE username = ? AND property = ?`,
195
+ { replacements: [username, property] }
196
+ )
197
+ }
198
+
199
+ async removeFromCollection (username, property, object) {
200
+ assert.ok(username, 'username is required')
201
+ assert.equal(typeof username, 'string', 'username must be a string')
202
+ assert.ok(property, 'property is required')
203
+ assert.equal(typeof property, 'string', 'property must be a string')
204
+ assert.ok(object, 'object is required')
205
+ assert.equal(typeof object, 'object', 'object must be an object')
206
+
207
+ if (!await this.isInCollection(username, property, object)) {
208
+ return 0
209
+ }
210
+
211
+ await this.#connection.query(
212
+ `DELETE FROM actorcollectionpage
213
+ WHERE username = ? AND property = ? AND item = ?`,
214
+ { replacements: [username, property, object.id] }
215
+ )
216
+
217
+ await this.#connection.query(
218
+ `UPDATE actorcollection
219
+ SET totalItems = totalItems - 1, updatedAt = CURRENT_TIMESTAMP
220
+ WHERE username = ? AND property = ?`,
221
+ { replacements: [username, property] }
222
+ )
223
+
224
+ return 1
225
+ }
226
+
227
+ async * items (username, property) {
228
+ assert.ok(username, 'username is required')
229
+ assert.equal(typeof username, 'string', 'username must be a string')
230
+ assert.ok(property, 'property is required')
231
+ assert.equal(typeof property, 'string', 'property must be a string')
232
+
233
+ const result = await this.#connection.query(
234
+ `SELECT item
235
+ FROM actorcollectionpage
236
+ WHERE username = ? AND property = ?
237
+ ORDER BY page DESC, createdAt DESC, item DESC;`,
238
+ { replacements: [username, property] }
239
+ )
240
+ for (const row of result[0]) {
241
+ yield as2.import({ id: row.item })
242
+ }
243
+ }
244
+
245
+ async isInCollection (username, property, object) {
246
+ assert.ok(username, 'username is required')
247
+ assert.equal(typeof username, 'string', 'username must be a string')
248
+ assert.ok(property, 'property is required')
249
+ assert.equal(typeof property, 'string', 'property must be a string')
250
+ assert.ok(object, 'object is required')
251
+ assert.equal(typeof object, 'object', 'object must be an object')
252
+ const [result] = await this.#connection.query(
253
+ `SELECT COUNT(*) as item_count
254
+ FROM actorcollectionpage
255
+ WHERE username = ? AND property = ? AND item = ?`,
256
+ { replacements: [username, property, object.id] }
257
+ )
258
+ return result[0].item_count > 0
259
+ }
260
+
261
+ async hasPage (username, property, page) {
262
+ const [, first] = await this.#getCollectionInfo(
263
+ username,
264
+ property
265
+ )
266
+ return page <= first
267
+ }
268
+
269
+ async #getCollectionInfo (username, property) {
270
+ const [result] = await this.#connection.query(
271
+ `SELECT first, totalItems, createdAt, updatedAt
272
+ FROM actorcollection
273
+ WHERE username = ? AND property = ?;`,
274
+ { replacements: [username, property] }
275
+ )
276
+ if (result.length > 0) {
277
+ const row = result[0]
278
+ return [row.totalItems, row.first, row.createdAt, row.updatedAt]
279
+ } else {
280
+ return [0, 1, null, null]
281
+ }
282
+ }
283
+
284
+ async #itemCount (username, property, page) {
285
+ assert.ok(this.#connection, 'ActorStorage not initialized')
286
+ assert.ok(username, 'username is required')
287
+ assert.equal(typeof username, 'string', 'username must be a string')
288
+ assert.ok(property, 'property is required')
289
+ assert.equal(typeof property, 'string', 'property must be a string')
290
+ assert.ok(page, 'page is required')
291
+ assert.equal(typeof page, 'number', 'page must be a number')
292
+ assert.ok(page >= 1, 'page must be greater than or equal to 1')
293
+ const rows = await this.#connection.query(
294
+ `SELECT COUNT(*) as item_count FROM actorcollectionpage
295
+ WHERE username = ? AND property = ? AND page = ?`,
296
+ { replacements: [username, property, page] }
297
+ )
298
+ return rows[0][0].item_count
299
+ }
300
+ }
package/lib/app.js ADDED
@@ -0,0 +1,173 @@
1
+ import { Sequelize } from 'sequelize'
2
+ import express from 'express'
3
+ import Logger from 'pino'
4
+ import HTTPLogger from 'pino-http'
5
+ import http from 'node:http'
6
+ import { ActivityDistributor } from './activitydistributor.js'
7
+ import { ActivityPubClient } from './activitypubclient.js'
8
+ import { ActorStorage } from './actorstorage.js'
9
+ import { BotDataStorage } from './botdatastorage.js'
10
+ import { KeyStorage } from './keystorage.js'
11
+ import { ObjectStorage } from './objectstorage.js'
12
+ import { UrlFormatter } from './urlformatter.js'
13
+ import { HTTPSignature } from './httpsignature.js'
14
+ import { Authorizer } from './authorizer.js'
15
+ import { RemoteKeyStorage } from './remotekeystorage.js'
16
+ import { ActivityHandler } from './activityhandler.js'
17
+ import { ObjectCache } from '../lib/objectcache.js'
18
+ import serverRouter from './routes/server.js'
19
+ import userRouter from './routes/user.js'
20
+ import objectRouter from './routes/object.js'
21
+ import collectionRouter from './routes/collection.js'
22
+ import inboxRouter from './routes/inbox.js'
23
+ import healthRouter from './routes/health.js'
24
+ import webfingerRouter from './routes/webfinger.js'
25
+ import { BotContext } from './botcontext.js'
26
+ import { Transformer } from './microsyntax.js'
27
+ import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js'
28
+ import { Digester } from './digester.js'
29
+
30
+ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
31
+ const logger = Logger({
32
+ level: logLevel
33
+ })
34
+ logger.debug('Logger initialized')
35
+ const connection = new Sequelize(databaseUrl, { logging: false })
36
+ const formatter = new UrlFormatter(origin)
37
+ const signer = new HTTPSignature(logger)
38
+ const digester = new Digester(logger)
39
+ const actorStorage = new ActorStorage(connection, formatter)
40
+ await actorStorage.initialize()
41
+ const botDataStorage = new BotDataStorage(connection)
42
+ await botDataStorage.initialize()
43
+ const keyStorage = new KeyStorage(connection, logger)
44
+ await keyStorage.initialize()
45
+ const objectStorage = new ObjectStorage(connection)
46
+ await objectStorage.initialize()
47
+ const client =
48
+ new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
49
+ const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
50
+ await remoteKeyStorage.initialize()
51
+ const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
52
+ const distributor = new ActivityDistributor(
53
+ client,
54
+ formatter,
55
+ actorStorage,
56
+ logger
57
+ )
58
+ const authorizer = new Authorizer(actorStorage, formatter, client)
59
+ const cache = new ObjectCache({
60
+ longTTL: 3600 * 1000,
61
+ shortTTL: 300 * 1000,
62
+ maxItems: 1000
63
+ })
64
+ const activityHandler = new ActivityHandler(
65
+ actorStorage,
66
+ objectStorage,
67
+ distributor,
68
+ formatter,
69
+ cache,
70
+ authorizer,
71
+ logger,
72
+ client
73
+ )
74
+ // TODO: Make an endpoint for tagged objects
75
+ const transformer = new Transformer(origin + '/tag/', client)
76
+ await Promise.all(
77
+ Object.values(bots).map(bot => bot.initialize(
78
+ new BotContext(
79
+ bot.username,
80
+ botDataStorage,
81
+ objectStorage,
82
+ actorStorage,
83
+ client,
84
+ distributor,
85
+ formatter,
86
+ transformer,
87
+ logger
88
+ )
89
+ ))
90
+ )
91
+
92
+ const app = express()
93
+
94
+ app.locals = {
95
+ connection,
96
+ formatter,
97
+ actorStorage,
98
+ botDataStorage,
99
+ keyStorage,
100
+ objectStorage,
101
+ remoteKeyStorage,
102
+ client,
103
+ distributor,
104
+ signature,
105
+ logger,
106
+ authorizer,
107
+ bots,
108
+ activityHandler,
109
+ origin
110
+ }
111
+
112
+ app.use(HTTPLogger({
113
+ logger,
114
+ level: logLevel
115
+ }))
116
+
117
+ app.use(express.json({
118
+ type: [
119
+ 'application/activity+json',
120
+ 'application/ld+json',
121
+ 'application/json'
122
+ ],
123
+ verify: (req, res, buf, encoding) => {
124
+ req.rawBodyText = buf.toString(encoding || 'utf8')
125
+ }
126
+ }))
127
+
128
+ app.use(signature.authenticate.bind(signature))
129
+
130
+ app.use('/', serverRouter)
131
+ app.use('/', userRouter)
132
+ app.use('/', collectionRouter)
133
+ app.use('/', objectRouter)
134
+ app.use('/', inboxRouter)
135
+ app.use('/', healthRouter)
136
+ app.use('/', webfingerRouter)
137
+
138
+ app.use((err, req, res, next) => {
139
+ const { logger } = req.app.locals
140
+ let status = 500
141
+ if (err.status) {
142
+ status = err.status
143
+ }
144
+ const title = (http.STATUS_CODES[status])
145
+ ? http.STATUS_CODES[status]
146
+ : 'Unknown Status'
147
+
148
+ if (status >= 500 && status < 600) {
149
+ logger.error(err)
150
+ } else if (status >= 400 && status < 500) {
151
+ logger.warn(err)
152
+ } else {
153
+ logger.debug(err)
154
+ }
155
+ res.status(status)
156
+ res.type('application/problem+json')
157
+ res.json({ type: 'about:blank', title, status, detail: err.message })
158
+ })
159
+
160
+ app.onIdle = async () => {
161
+ await distributor.onIdle()
162
+ }
163
+
164
+ app.cleanup = async () => {
165
+ logger.info('Closing app')
166
+ logger.info('Waiting for distributor queue')
167
+ await distributor.onIdle()
168
+ logger.info('Closing database connection')
169
+ await connection.close()
170
+ }
171
+
172
+ return app
173
+ }