@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.
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/main-docker.yml +45 -0
- package/.github/workflows/tag-docker.yml +54 -0
- package/Dockerfile +23 -0
- package/LICENSE +661 -0
- package/README.md +82 -0
- package/bots/index.js +7 -0
- package/docs/activitypub.bot.drawio +110 -0
- package/index.js +23 -0
- package/lib/activitydistributor.js +263 -0
- package/lib/activityhandler.js +999 -0
- package/lib/activitypubclient.js +126 -0
- package/lib/activitystreams.js +41 -0
- package/lib/actorstorage.js +300 -0
- package/lib/app.js +173 -0
- package/lib/authorizer.js +133 -0
- package/lib/bot.js +44 -0
- package/lib/botcontext.js +520 -0
- package/lib/botdatastorage.js +87 -0
- package/lib/bots/donothing.js +11 -0
- package/lib/bots/ok.js +41 -0
- package/lib/digester.js +23 -0
- package/lib/httpsignature.js +195 -0
- package/lib/httpsignatureauthenticator.js +81 -0
- package/lib/keystorage.js +113 -0
- package/lib/microsyntax.js +140 -0
- package/lib/objectcache.js +48 -0
- package/lib/objectstorage.js +319 -0
- package/lib/remotekeystorage.js +116 -0
- package/lib/routes/collection.js +92 -0
- package/lib/routes/health.js +24 -0
- package/lib/routes/inbox.js +83 -0
- package/lib/routes/object.js +69 -0
- package/lib/routes/server.js +47 -0
- package/lib/routes/user.js +63 -0
- package/lib/routes/webfinger.js +36 -0
- package/lib/urlformatter.js +97 -0
- package/package.json +51 -0
- package/tests/activitydistributor.test.js +606 -0
- package/tests/activityhandler.test.js +2185 -0
- package/tests/activitypubclient.test.js +225 -0
- package/tests/actorstorage.test.js +261 -0
- package/tests/app.test.js +17 -0
- package/tests/authorizer.test.js +306 -0
- package/tests/bot.donothing.test.js +30 -0
- package/tests/bot.ok.test.js +101 -0
- package/tests/botcontext.test.js +674 -0
- package/tests/botdatastorage.test.js +87 -0
- package/tests/digester.test.js +56 -0
- package/tests/fixtures/bots.js +15 -0
- package/tests/httpsignature.test.js +200 -0
- package/tests/httpsignatureauthenticator.test.js +463 -0
- package/tests/keystorage.test.js +89 -0
- package/tests/microsyntax.test.js +122 -0
- package/tests/objectcache.test.js +133 -0
- package/tests/objectstorage.test.js +148 -0
- package/tests/remotekeystorage.test.js +76 -0
- package/tests/routes.actor.test.js +207 -0
- package/tests/routes.collection.test.js +434 -0
- package/tests/routes.health.test.js +41 -0
- package/tests/routes.inbox.test.js +135 -0
- package/tests/routes.object.test.js +519 -0
- package/tests/routes.server.test.js +69 -0
- package/tests/routes.webfinger.test.js +41 -0
- package/tests/urlformatter.test.js +164 -0
- package/tests/utils/digest.js +7 -0
- package/tests/utils/nock.js +276 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import TTLCache from '@isaacs/ttlcache'
|
|
2
|
+
|
|
3
|
+
export class ObjectCache {
|
|
4
|
+
#objects = null
|
|
5
|
+
#members = null
|
|
6
|
+
constructor ({ longTTL, shortTTL, maxItems }) {
|
|
7
|
+
this.#objects = new TTLCache({ ttl: shortTTL, max: maxItems })
|
|
8
|
+
this.#members = new TTLCache({ ttl: shortTTL, max: maxItems })
|
|
9
|
+
this.longTTL = longTTL
|
|
10
|
+
this.shortTTL = shortTTL
|
|
11
|
+
this.maxItems = maxItems
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async initialize () {
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async get (id) {
|
|
18
|
+
return this.#objects.get(id)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async save (object) {
|
|
22
|
+
return this.#objects.set(object.id, object, { ttl: this.longTTL })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async saveReceived (object) {
|
|
26
|
+
return this.#objects.set(object.id, object, { ttl: this.shortTTL })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async clear (object) {
|
|
30
|
+
return this.#objects.delete(object.id)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
membershipKey (collection, object) {
|
|
34
|
+
return `${collection.id}:${object.id}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async saveMembership (collection, object, isMember = true) {
|
|
38
|
+
return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.longTTL })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async saveMembershipReceived (collection, object, isMember = true) {
|
|
42
|
+
return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.shortTTL })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async isMember (collection, object) {
|
|
46
|
+
return this.#members.get(this.membershipKey(collection, object))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import as2 from './activitystreams.js'
|
|
3
|
+
|
|
4
|
+
export class NoSuchObjectError extends Error {
|
|
5
|
+
constructor (id) {
|
|
6
|
+
const message = `No such object: ${id}`
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'NoSuchObjectError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ObjectStorage {
|
|
13
|
+
#connection = null
|
|
14
|
+
static #MAX_ITEMS_PER_PAGE = 20
|
|
15
|
+
|
|
16
|
+
constructor (connection) {
|
|
17
|
+
this.#connection = connection
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async initialize () {
|
|
21
|
+
await this.#connection.query(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS objects (
|
|
23
|
+
id VARCHAR(512) PRIMARY KEY,
|
|
24
|
+
data TEXT NOT NULL,
|
|
25
|
+
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
26
|
+
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
27
|
+
)
|
|
28
|
+
`)
|
|
29
|
+
await this.#connection.query(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS collections (
|
|
31
|
+
id VARCHAR(512) NOT NULL,
|
|
32
|
+
property VARCHAR(512) NOT NULL,
|
|
33
|
+
first INTEGER NOT NULL,
|
|
34
|
+
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
35
|
+
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
36
|
+
PRIMARY KEY (id, property)
|
|
37
|
+
)
|
|
38
|
+
`)
|
|
39
|
+
await this.#connection.query(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
41
|
+
id VARCHAR(512) NOT NULL,
|
|
42
|
+
property VARCHAR(64) NOT NULL,
|
|
43
|
+
item VARCHAR(512) NOT NULL,
|
|
44
|
+
page INTEGER NOT NULL,
|
|
45
|
+
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
46
|
+
PRIMARY KEY (id, property, item)
|
|
47
|
+
)
|
|
48
|
+
`)
|
|
49
|
+
|
|
50
|
+
await this.#connection.query(
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS pages_username_property_page
|
|
52
|
+
ON pages (id, property, page);`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async create (object) {
|
|
57
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
58
|
+
assert.ok(object, 'object is required')
|
|
59
|
+
assert.ok(object.id, 'object.id is required')
|
|
60
|
+
const id = object.id
|
|
61
|
+
const data = await object.write()
|
|
62
|
+
assert.ok(data, 'object is required')
|
|
63
|
+
assert.ok(typeof data === 'string', 'data must be a string')
|
|
64
|
+
await this.#connection.query(
|
|
65
|
+
'INSERT INTO objects (id, data) VALUES (?, ?)',
|
|
66
|
+
{ replacements: [id, data] }
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async read (id) {
|
|
71
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
72
|
+
assert.ok(id, 'id is required')
|
|
73
|
+
const data = await this.#connection.query(
|
|
74
|
+
'SELECT data FROM objects WHERE id = ?',
|
|
75
|
+
{ replacements: [id] }
|
|
76
|
+
)
|
|
77
|
+
if (data[0].length === 0) {
|
|
78
|
+
throw new NoSuchObjectError(id)
|
|
79
|
+
}
|
|
80
|
+
return await as2.import(JSON.parse(data[0][0].data))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async update (object) {
|
|
84
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
85
|
+
assert.ok(object, 'object is required')
|
|
86
|
+
assert.ok(object.id, 'object.id is required')
|
|
87
|
+
const id = object.id
|
|
88
|
+
const data = await object.write()
|
|
89
|
+
assert.ok(data, 'object is required')
|
|
90
|
+
assert.ok(typeof data === 'string', 'data must be a string')
|
|
91
|
+
await this.#connection.query(
|
|
92
|
+
'UPDATE objects SET data = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ?',
|
|
93
|
+
{ replacements: [data, id] }
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async delete (object) {
|
|
98
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
99
|
+
assert.ok(object, 'object is required')
|
|
100
|
+
assert.ok(object.id, 'object.id is required')
|
|
101
|
+
const id = object.id
|
|
102
|
+
await this.#connection.query('DELETE FROM objects WHERE id = ?', {
|
|
103
|
+
replacements: [id]
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async #collectionInfo (id, property) {
|
|
108
|
+
assert.ok(id, 'id is required')
|
|
109
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
110
|
+
assert.ok(property, 'property is required')
|
|
111
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
112
|
+
let totalItems = 0
|
|
113
|
+
let first = 1
|
|
114
|
+
let createdAt = null
|
|
115
|
+
const row = await this.#connection.query(
|
|
116
|
+
'SELECT first, createdAt FROM collections WHERE id = ? AND property = ?',
|
|
117
|
+
{ replacements: [id, property] }
|
|
118
|
+
)
|
|
119
|
+
if (row[0].length > 0) {
|
|
120
|
+
first = row[0][0].first
|
|
121
|
+
createdAt = row[0][0].createdAt
|
|
122
|
+
}
|
|
123
|
+
const count = await this.#connection.query(
|
|
124
|
+
'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ?',
|
|
125
|
+
{ replacements: [id, property] }
|
|
126
|
+
)
|
|
127
|
+
if (count[0].length > 0) {
|
|
128
|
+
totalItems = count[0][0]['COUNT(*)']
|
|
129
|
+
}
|
|
130
|
+
assert.equal(typeof totalItems, 'number', 'totalItems must be a number')
|
|
131
|
+
assert.ok(totalItems >= 0, 'totalItems must be greater than or equal to 0')
|
|
132
|
+
assert.equal(typeof first, 'number', 'first must be a number')
|
|
133
|
+
assert.ok(first >= 1, 'first must be greater than or equal to 1')
|
|
134
|
+
return [totalItems, first, createdAt]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async getCollection (id, property) {
|
|
138
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
139
|
+
assert.ok(id, 'id is required')
|
|
140
|
+
assert.ok(property, 'property is required')
|
|
141
|
+
const [totalItems, first, createdAt] = await this.#collectionInfo(
|
|
142
|
+
id,
|
|
143
|
+
property
|
|
144
|
+
)
|
|
145
|
+
const context = [
|
|
146
|
+
'https://www.w3.org/ns/activitystreams'
|
|
147
|
+
]
|
|
148
|
+
if (property === 'thread') {
|
|
149
|
+
context.push('https://purl.archive.org/socialweb/thread/1.0')
|
|
150
|
+
} else {
|
|
151
|
+
context.push('https://w3id.org/fep/5711')
|
|
152
|
+
}
|
|
153
|
+
const inverse = (property === 'thread') ? 'root' : `${property}Of`
|
|
154
|
+
const collection = await as2.import({
|
|
155
|
+
'@context': context,
|
|
156
|
+
id: `${id}/${property}`,
|
|
157
|
+
type: 'OrderedCollection',
|
|
158
|
+
totalItems,
|
|
159
|
+
first: `${id}/${property}/${first}`,
|
|
160
|
+
last: `${id}/${property}/1`,
|
|
161
|
+
published: createdAt,
|
|
162
|
+
[inverse]: id
|
|
163
|
+
})
|
|
164
|
+
assert.ok(collection, 'collection is required')
|
|
165
|
+
assert.equal(typeof collection, 'object', 'collection must be an object')
|
|
166
|
+
return collection
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getCollectionPage (id, property, page) {
|
|
170
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
171
|
+
assert.ok(id, 'id is required')
|
|
172
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
173
|
+
assert.ok(property, 'property is required')
|
|
174
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
175
|
+
assert.ok(page, 'pageNo is required')
|
|
176
|
+
assert.equal(typeof page, 'number', 'page must be a number')
|
|
177
|
+
assert.ok(page >= 1, 'page must be greater than or equal to 1')
|
|
178
|
+
|
|
179
|
+
const [, first] = await this.#collectionInfo(
|
|
180
|
+
id,
|
|
181
|
+
property
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if (page > first) {
|
|
185
|
+
throw new NoSuchObjectError(`${id}/${property}/${page}`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let items = []
|
|
189
|
+
|
|
190
|
+
const rows = await this.#connection.query(
|
|
191
|
+
'SELECT item FROM pages WHERE id = ? AND property = ? and page = ? ORDER BY createdAt DESC, item DESC',
|
|
192
|
+
{ replacements: [id, property, page] }
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (rows[0].length > 0) {
|
|
196
|
+
items = rows[0].map((row) => row.item)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const pageObject = await as2.import({
|
|
200
|
+
id: `${id}/${property}/${page}`,
|
|
201
|
+
type: 'OrderedCollectionPage',
|
|
202
|
+
partOf: `${id}/${property}`,
|
|
203
|
+
next: page === 1 ? null : `${id}/${property}/${page - 1}`,
|
|
204
|
+
prev: page === first ? null : `${id}/${property}/${page + 1}`,
|
|
205
|
+
items
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
assert.ok(pageObject, 'collection is required')
|
|
209
|
+
assert.equal(typeof pageObject, 'object', 'collection must be an object')
|
|
210
|
+
|
|
211
|
+
return pageObject
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async addToCollection (id, property, object) {
|
|
215
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
216
|
+
assert.ok(id, 'id is required')
|
|
217
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
218
|
+
assert.ok(property, 'property is required')
|
|
219
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
220
|
+
assert.ok(object, 'object is required')
|
|
221
|
+
assert.equal(typeof object, 'object', 'object must be an object')
|
|
222
|
+
|
|
223
|
+
const [, first, createdAt] = await this.#collectionInfo(
|
|
224
|
+
id,
|
|
225
|
+
property
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if (createdAt === null) {
|
|
229
|
+
await this.#connection.query(
|
|
230
|
+
'INSERT INTO collections (id, property, first) VALUES (?, ?, 1)',
|
|
231
|
+
{ replacements: [id, property] }
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const count = await this.#itemCount(id, property, first)
|
|
236
|
+
|
|
237
|
+
const page = count >= ObjectStorage.#MAX_ITEMS_PER_PAGE ? first + 1 : first
|
|
238
|
+
|
|
239
|
+
if (page > first) {
|
|
240
|
+
await this.#connection.query(
|
|
241
|
+
'UPDATE collections SET first = ?, updatedAt = CURRENT_TIMESTAMP WHERE id = ? AND property = ?',
|
|
242
|
+
{ replacements: [page, id, property] }
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await this.#connection.query(
|
|
247
|
+
'INSERT INTO pages (id, property, item, page) VALUES (?, ?, ?, ?)',
|
|
248
|
+
{ replacements: [id, property, object.id, page] }
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async #itemCount (id, property, page) {
|
|
253
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
254
|
+
assert.ok(id, 'id is required')
|
|
255
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
256
|
+
assert.ok(property, 'property is required')
|
|
257
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
258
|
+
assert.ok(page, 'page is required')
|
|
259
|
+
assert.equal(typeof page, 'number', 'page must be a number')
|
|
260
|
+
assert.ok(page >= 1, 'page must be greater than or equal to 1')
|
|
261
|
+
const rows = await this.#connection.query(
|
|
262
|
+
'SELECT COUNT(*) FROM pages WHERE id = ? AND property = ? AND page = ?',
|
|
263
|
+
{ replacements: [id, property, page] }
|
|
264
|
+
)
|
|
265
|
+
return rows[0][0]['COUNT(*)']
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async removeFromCollection (id, property, object) {
|
|
269
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
270
|
+
assert.ok(id, 'id is required')
|
|
271
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
272
|
+
assert.ok(property, 'property is required')
|
|
273
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
274
|
+
assert.ok(object, 'object is required')
|
|
275
|
+
assert.equal(typeof object, 'object', 'object must be an object')
|
|
276
|
+
|
|
277
|
+
await this.#connection.query(
|
|
278
|
+
'DELETE FROM pages WHERE id = ? AND property = ? AND item = ?',
|
|
279
|
+
{ replacements: [id, property, object.id] }
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async isInCollection (id, property, object) {
|
|
284
|
+
assert.ok(this.#connection, 'ObjectStorage not initialized')
|
|
285
|
+
assert.ok(id, 'id is required')
|
|
286
|
+
assert.equal(typeof id, 'string', 'id must be a string')
|
|
287
|
+
assert.ok(property, 'property is required')
|
|
288
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
289
|
+
assert.ok(object, 'object is required')
|
|
290
|
+
assert.equal(typeof object, 'object', 'object must be an object')
|
|
291
|
+
assert.ok(object.id, 'object.id is required')
|
|
292
|
+
assert.equal(typeof object.id, 'string', 'object.id must be a string')
|
|
293
|
+
const [result] = await this.#connection.query(
|
|
294
|
+
`SELECT COUNT(*) as item_count
|
|
295
|
+
FROM pages
|
|
296
|
+
WHERE id = ? AND property = ? AND item = ?`,
|
|
297
|
+
{ replacements: [id, property, object.id] }
|
|
298
|
+
)
|
|
299
|
+
return result[0].item_count > 0
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async * items (id, property) {
|
|
303
|
+
assert.ok(id, 'username is required')
|
|
304
|
+
assert.equal(typeof id, 'string', 'username must be a string')
|
|
305
|
+
assert.ok(property, 'property is required')
|
|
306
|
+
assert.equal(typeof property, 'string', 'property must be a string')
|
|
307
|
+
|
|
308
|
+
const result = await this.#connection.query(
|
|
309
|
+
`SELECT item
|
|
310
|
+
FROM pages
|
|
311
|
+
WHERE id = ? AND property = ?
|
|
312
|
+
ORDER BY page DESC, createdAt DESC, item DESC;`,
|
|
313
|
+
{ replacements: [id, property] }
|
|
314
|
+
)
|
|
315
|
+
for (const row of result[0]) {
|
|
316
|
+
yield as2.import({ id: row.item })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const SEC_NS = 'https://w3id.org/security#'
|
|
2
|
+
|
|
3
|
+
export class RemoteKeyStorage {
|
|
4
|
+
#client = null
|
|
5
|
+
#connection = null
|
|
6
|
+
#logger = null
|
|
7
|
+
constructor (client, connection, logger) {
|
|
8
|
+
this.#client = client
|
|
9
|
+
this.#connection = connection
|
|
10
|
+
this.#logger = logger.child({ class: this.constructor.name })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async initialize () {
|
|
14
|
+
await this.#connection.query(
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS new_remotekeys (
|
|
16
|
+
id VARCHAR(512) PRIMARY KEY,
|
|
17
|
+
owner VARCHAR(512) NOT NULL,
|
|
18
|
+
publicKeyPem TEXT NOT NULL,
|
|
19
|
+
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
20
|
+
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
21
|
+
)`
|
|
22
|
+
)
|
|
23
|
+
try {
|
|
24
|
+
await this.#connection.query(
|
|
25
|
+
`INSERT OR IGNORE INTO new_remotekeys (id, owner, publicKeyPem)
|
|
26
|
+
SELECT id, owner, publicKeyPem
|
|
27
|
+
FROM remotekeys`
|
|
28
|
+
)
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.#logger.debug(
|
|
31
|
+
{ error, method: 'RemoteKeyStorage.initialize' },
|
|
32
|
+
'failed to copy remotekeys to new_remotekeys table'
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getPublicKey (id, useCache = true) {
|
|
38
|
+
this.debug(`getPublicKey(${id})`)
|
|
39
|
+
if (useCache) {
|
|
40
|
+
this.debug('using cache')
|
|
41
|
+
const cached = await this.#getCachedPublicKey(id)
|
|
42
|
+
if (cached) {
|
|
43
|
+
this.debug(`getPublicKey(${id}) - cached`)
|
|
44
|
+
return cached
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
this.debug('skipping cache')
|
|
48
|
+
}
|
|
49
|
+
const remote = await this.#getRemotePublicKey(id)
|
|
50
|
+
if (!remote) {
|
|
51
|
+
this.debug(`getPublicKey(${id}) - remote not found`)
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
await this.#cachePublicKey(id, remote.owner, remote.publicKeyPem)
|
|
55
|
+
return remote
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async #getCachedPublicKey (id) {
|
|
59
|
+
this.debug(`#getCachedPublicKey(${id})`)
|
|
60
|
+
const [result] = await this.#connection.query(
|
|
61
|
+
'SELECT publicKeyPem, owner FROM new_remotekeys WHERE id = ?',
|
|
62
|
+
{ replacements: [id] }
|
|
63
|
+
)
|
|
64
|
+
if (result.length > 0) {
|
|
65
|
+
this.debug(`cache hit for ${id}`)
|
|
66
|
+
return {
|
|
67
|
+
publicKeyPem: result[0].publicKeyPem,
|
|
68
|
+
owner: result[0].owner
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
this.debug(`cache miss for ${id}`)
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #getRemotePublicKey (id) {
|
|
77
|
+
this.debug(`#getRemotePublicKey(${id})`)
|
|
78
|
+
const response = await this.#client.getKey(id)
|
|
79
|
+
if (!response) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
this.debug(`getRemotePublicKey(${id}) - response: ${await response.id}`)
|
|
83
|
+
let owner = null
|
|
84
|
+
let publicKeyPem = null
|
|
85
|
+
if (response.get(SEC_NS + 'publicKeyPem')) {
|
|
86
|
+
this.debug(`getRemotePublicKey(${id}) - publicKeyPem`)
|
|
87
|
+
owner = response.get(SEC_NS + 'owner')?.first?.id
|
|
88
|
+
publicKeyPem = response.get(SEC_NS + 'publicKeyPem')?.first
|
|
89
|
+
} else if (response.get(SEC_NS + 'publicKey')) {
|
|
90
|
+
this.debug(`getRemotePublicKey(${id}) - publicKey`)
|
|
91
|
+
const publicKey = response.get(SEC_NS + 'publicKey').first
|
|
92
|
+
if (publicKey) {
|
|
93
|
+
owner = publicKey.get(SEC_NS + 'owner')?.first?.id
|
|
94
|
+
publicKeyPem = publicKey.get(SEC_NS + 'publicKeyPem')?.first
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!owner || !publicKeyPem) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
return { owner, publicKeyPem }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #cachePublicKey (id, owner, publicKeyPem) {
|
|
104
|
+
await this.#connection.query(
|
|
105
|
+
'INSERT INTO new_remotekeys (id, owner, publicKeyPem) VALUES (?, ?, ?) ' + ' ON CONFLICT(id) DO UPDATE ' +
|
|
106
|
+
'SET owner=excluded.owner, publicKeyPem = excluded.publicKeyPem;',
|
|
107
|
+
{ replacements: [id, owner, publicKeyPem] }
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
debug (...args) {
|
|
112
|
+
if (this.#logger) {
|
|
113
|
+
this.#logger.debug(...args)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import as2 from '../activitystreams.js'
|
|
3
|
+
import createHttpError from 'http-errors'
|
|
4
|
+
|
|
5
|
+
const router = express.Router()
|
|
6
|
+
|
|
7
|
+
async function filterAsync (array, asyncPredicate) {
|
|
8
|
+
// 1. Kick off all predicate calls in parallel:
|
|
9
|
+
const checks = array.map(item => asyncPredicate(item))
|
|
10
|
+
|
|
11
|
+
// 2. Wait for all to settle into [true, false, …]:
|
|
12
|
+
const booleans = await Promise.all(checks)
|
|
13
|
+
|
|
14
|
+
// 3. Pick only those whose boolean was true:
|
|
15
|
+
return array.filter((_, idx) => booleans[idx])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
router.get('/user/:username/:collection', async (req, res, next) => {
|
|
19
|
+
const { username, collection } = req.params
|
|
20
|
+
const { actorStorage, bots } = req.app.locals
|
|
21
|
+
if (!(username in bots)) {
|
|
22
|
+
return next(createHttpError(404, `User ${username} not found`))
|
|
23
|
+
}
|
|
24
|
+
if (collection === 'inbox') {
|
|
25
|
+
return next(createHttpError(403, `No access to ${collection} collection`))
|
|
26
|
+
}
|
|
27
|
+
if (!['outbox', 'liked', 'followers', 'following'].includes(req.params.collection)) {
|
|
28
|
+
return next(createHttpError(404,
|
|
29
|
+
`No such collection ${collection} for user ${username}`))
|
|
30
|
+
}
|
|
31
|
+
const coll = await actorStorage.getCollection(username, collection)
|
|
32
|
+
res.status(200)
|
|
33
|
+
res.type(as2.mediaType)
|
|
34
|
+
res.end(await coll.prettyWrite({ useOriginalContext: true }))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
router.get('/user/:username/:collection/:n(\\d+)', async (req, res, next) => {
|
|
38
|
+
const { username, collection, n } = req.params
|
|
39
|
+
const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
|
|
40
|
+
|
|
41
|
+
if (!(username in bots)) {
|
|
42
|
+
return next(createHttpError(404, `User ${username} not found`))
|
|
43
|
+
}
|
|
44
|
+
if (collection === 'inbox') {
|
|
45
|
+
return next(createHttpError(403, `No access to ${collection} collection`))
|
|
46
|
+
}
|
|
47
|
+
if (!['outbox', 'liked', 'followers', 'following'].includes(collection)) {
|
|
48
|
+
return next(createHttpError(404,
|
|
49
|
+
`No such collection ${collection} for user ${username}`))
|
|
50
|
+
}
|
|
51
|
+
if (!await actorStorage.hasPage(username, collection, parseInt(n))) {
|
|
52
|
+
return next(createHttpError(404, `No such page ${n} for collection ${collection} for user ${username}`))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let exported = null
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const id = req.auth?.subject
|
|
59
|
+
const remote = (id) ? await as2.import({ id }) : null
|
|
60
|
+
const page = await actorStorage.getCollectionPage(
|
|
61
|
+
username,
|
|
62
|
+
collection,
|
|
63
|
+
parseInt(n)
|
|
64
|
+
)
|
|
65
|
+
exported = await page.export({ useOriginalContext: true })
|
|
66
|
+
|
|
67
|
+
if (['outbox', 'liked'].includes(collection)) {
|
|
68
|
+
exported.items = await filterAsync(exported.items, async (id) => {
|
|
69
|
+
const object = (formatter.isLocal(id))
|
|
70
|
+
? await objectStorage.read(id)
|
|
71
|
+
: await client.get(id)
|
|
72
|
+
if (!object) {
|
|
73
|
+
req.log.warn({ id }, 'could not load object')
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
req.log.debug({ id, object }, 'loaded object')
|
|
77
|
+
return await authorizer.canRead(remote, object)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
req.log.error(
|
|
82
|
+
{ err: error, username, collection, n },
|
|
83
|
+
'error loading collection page'
|
|
84
|
+
)
|
|
85
|
+
return next(createHttpError(500, 'Error loading collection page'))
|
|
86
|
+
}
|
|
87
|
+
res.status(200)
|
|
88
|
+
res.type(as2.mediaType)
|
|
89
|
+
res.end(JSON.stringify(exported))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
export default router
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import createHttpError from 'http-errors'
|
|
3
|
+
|
|
4
|
+
const router = express.Router()
|
|
5
|
+
|
|
6
|
+
router.get('/livez', async (req, res) => {
|
|
7
|
+
res.status(200)
|
|
8
|
+
res.type('text/plain')
|
|
9
|
+
res.end('OK')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
router.get('/readyz', async (req, res, next) => {
|
|
13
|
+
const connection = req.app.locals.connection
|
|
14
|
+
try {
|
|
15
|
+
await connection.query('SELECT 1')
|
|
16
|
+
res.status(200)
|
|
17
|
+
res.type('text/plain')
|
|
18
|
+
res.end('OK')
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return next(createHttpError(503, 'Service Unavailable'))
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export default router
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import as2 from '../activitystreams.js'
|
|
3
|
+
import createHttpError from 'http-errors'
|
|
4
|
+
|
|
5
|
+
const router = express.Router()
|
|
6
|
+
|
|
7
|
+
function isActivity (object) {
|
|
8
|
+
return true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getActor (activity) {
|
|
12
|
+
return activity.actor?.first
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
router.post('/user/:username/inbox', async (req, res, next) => {
|
|
16
|
+
const { username } = req.params
|
|
17
|
+
const { bots, actorStorage, activityHandler } = req.app.locals
|
|
18
|
+
const { subject } = req.auth
|
|
19
|
+
const { logger } = req.app.locals
|
|
20
|
+
|
|
21
|
+
const bot = bots[username]
|
|
22
|
+
if (!bot) {
|
|
23
|
+
return next(createHttpError(404, 'Not Found'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!subject) {
|
|
27
|
+
return next(createHttpError(401, 'Unauthorized'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!req.body) {
|
|
31
|
+
return next(createHttpError(400, 'No request body provided'))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let activity
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
activity = await as2.import(req.body)
|
|
38
|
+
} catch (err) {
|
|
39
|
+
logger.warn('Failed to import activity', err)
|
|
40
|
+
logger.debug('Request body', req.body)
|
|
41
|
+
return next(createHttpError(400, 'Invalid request body'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!isActivity(activity)) {
|
|
45
|
+
return next(createHttpError(400, 'Request body is not an activity'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const actor = getActor(activity)
|
|
49
|
+
|
|
50
|
+
if (!actor) {
|
|
51
|
+
return next(createHttpError(400, 'No actor found in activity'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!actor.id) {
|
|
55
|
+
return next(createHttpError(400, 'No actor id found in activity'))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (actor.id !== subject) {
|
|
59
|
+
return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (await actorStorage.isInCollection(username, 'blocked', actor)) {
|
|
63
|
+
return next(createHttpError(403, 'Forbidden'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (await actorStorage.isInCollection(bot.username, 'inbox', activity)) {
|
|
67
|
+
return next(createHttpError(400, 'Activity already delivered'))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await activityHandler.handleActivity(bot, activity)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return next(err)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await actorStorage.addToCollection(bot.username, 'inbox', activity)
|
|
77
|
+
|
|
78
|
+
res.status(200)
|
|
79
|
+
res.type('text/plain')
|
|
80
|
+
res.send('OK')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export default router
|