@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,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
|
+
}
|