@evanp/activitypub-bot 0.30.6 → 0.31.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/README.md +2 -0
- package/lib/activitypubclient.js +15 -13
- package/lib/app.js +20 -0
- package/lib/bot.js +9 -1
- package/lib/bots/donothing.js +14 -2
- package/lib/bots/followback.js +4 -4
- package/lib/bots/relayclient.js +4 -4
- package/lib/httpsignatureauthenticator.js +21 -1
- package/lib/keystorage.js +6 -8
- package/lib/routes/server.js +0 -62
- package/lib/routes/webfinger.js +2 -28
- package/lib/urlformatter.js +7 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,8 @@ export default {
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
|
+
The bot name that matches the domain of the server is reserved for server activities (usually fetching remote objects that require a signature). If you use the domain name in the bot config, it will be silently overwritten. The domain name will also not be provided to the wildcard bot factory, if defined.
|
|
149
|
+
|
|
148
150
|
### Pre-installed bot classes
|
|
149
151
|
|
|
150
152
|
The following bot classes are pre-installed with the server.
|
package/lib/activitypubclient.js
CHANGED
|
@@ -50,10 +50,12 @@ export class ActivityPubClient {
|
|
|
50
50
|
this.#limiter = limiter
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async get (url, username =
|
|
53
|
+
async get (url, username = this.#urlFormatter.hostname) {
|
|
54
54
|
assert.ok(url)
|
|
55
55
|
assert.equal(typeof url, 'string')
|
|
56
|
-
assert.ok(username
|
|
56
|
+
assert.ok(username)
|
|
57
|
+
assert.equal(typeof username, 'string')
|
|
58
|
+
assert.ok(username !== '*')
|
|
57
59
|
const res = await this.#getRes(url, username, true)
|
|
58
60
|
return await this.#handleRes(res, url)
|
|
59
61
|
}
|
|
@@ -61,17 +63,19 @@ export class ActivityPubClient {
|
|
|
61
63
|
async getKey (url) {
|
|
62
64
|
assert.ok(url)
|
|
63
65
|
assert.equal(typeof url, 'string')
|
|
64
|
-
let res = await this.#getRes(url,
|
|
66
|
+
let res = await this.#getRes(url, this.#urlFormatter.hostname, false)
|
|
65
67
|
if ([401, 403, 404].includes(res.status)) {
|
|
66
68
|
// If we get a 401, 403, or 404, we should try again with the key
|
|
67
|
-
res = await this.#getRes(url,
|
|
69
|
+
res = await this.#getRes(url, this.#urlFormatter.hostname, true)
|
|
68
70
|
}
|
|
69
71
|
return await this.#handleRes(res, url)
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
async #getRes (url, username =
|
|
74
|
+
async #getRes (url, username = this.#urlFormatter.hostname, sign = false) {
|
|
73
75
|
assert.ok(url)
|
|
74
76
|
assert.equal(typeof url, 'string')
|
|
77
|
+
assert.ok(username)
|
|
78
|
+
assert.equal(typeof username, 'string')
|
|
75
79
|
const date = new Date().toUTCString()
|
|
76
80
|
const headers = {
|
|
77
81
|
accept: ActivityPubClient.#accept,
|
|
@@ -176,10 +180,9 @@ export class ActivityPubClient {
|
|
|
176
180
|
assert.ok(url)
|
|
177
181
|
assert.ok(method)
|
|
178
182
|
assert.ok(headers)
|
|
183
|
+
assert.ok(username)
|
|
179
184
|
const privateKey = await this.#keyStorage.getPrivateKey(username)
|
|
180
|
-
const keyId = (username)
|
|
181
|
-
? this.#urlFormatter.format({ username, type: 'publickey' })
|
|
182
|
-
: this.#urlFormatter.format({ server: true, type: 'publickey' })
|
|
185
|
+
const keyId = this.#urlFormatter.format({ username, type: 'publickey' })
|
|
183
186
|
return this.#signer.sign({ privateKey, keyId, url, method, headers })
|
|
184
187
|
}
|
|
185
188
|
|
|
@@ -189,13 +192,12 @@ export class ActivityPubClient {
|
|
|
189
192
|
: COLLECTION_TYPES.includes(obj.type)
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
async * items (id, username =
|
|
195
|
+
async * items (id, username = this.#urlFormatter.hostname) {
|
|
193
196
|
assert.ok(id)
|
|
194
197
|
assert.equal(typeof id, 'string')
|
|
195
|
-
assert.ok(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
)
|
|
198
|
+
assert.ok(username)
|
|
199
|
+
assert.equal(typeof username, 'string')
|
|
200
|
+
assert.ok(username !== '*')
|
|
199
201
|
|
|
200
202
|
const coll = await this.get(id, username)
|
|
201
203
|
|
package/lib/app.js
CHANGED
|
@@ -38,6 +38,7 @@ import { JobReaper } from './jobreaper.js'
|
|
|
38
38
|
import { DeliveryWorker } from './deliveryworker.js'
|
|
39
39
|
import { DistributionWorker } from './distributionworker.js'
|
|
40
40
|
import { RateLimiter } from '../lib/ratelimiter.js'
|
|
41
|
+
import DoNothingBot from './bots/donothing.js'
|
|
41
42
|
|
|
42
43
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
43
44
|
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
@@ -113,6 +114,25 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
113
114
|
))
|
|
114
115
|
)
|
|
115
116
|
|
|
117
|
+
// Special server bot
|
|
118
|
+
|
|
119
|
+
const domain = URL.parse(origin).hostname
|
|
120
|
+
bots[domain] = new DoNothingBot(domain, {
|
|
121
|
+
fullname: domain,
|
|
122
|
+
checkSignature: false
|
|
123
|
+
})
|
|
124
|
+
await bots[domain].initialize(new BotContext(
|
|
125
|
+
domain,
|
|
126
|
+
botDataStorage,
|
|
127
|
+
objectStorage,
|
|
128
|
+
actorStorage,
|
|
129
|
+
client,
|
|
130
|
+
distributor,
|
|
131
|
+
formatter,
|
|
132
|
+
transformer,
|
|
133
|
+
logger
|
|
134
|
+
))
|
|
135
|
+
|
|
116
136
|
const deliveryWorkers = new Array(deliveryWorkerCount)
|
|
117
137
|
const deliveryWorkerRuns = new Array(deliveryWorkerCount)
|
|
118
138
|
|
package/lib/bot.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
export default class Bot {
|
|
2
2
|
#context = null
|
|
3
3
|
#username = null
|
|
4
|
+
#checkSignature
|
|
4
5
|
|
|
5
|
-
constructor (username) {
|
|
6
|
+
constructor (username, options = {}) {
|
|
6
7
|
this.#username = username
|
|
8
|
+
this.#checkSignature = ('checkSignature' in options)
|
|
9
|
+
? options.checkSignature
|
|
10
|
+
: true
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
async initialize (context) {
|
|
@@ -25,6 +29,10 @@ export default class Bot {
|
|
|
25
29
|
return this.#username
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
get checkSignature () {
|
|
33
|
+
return this.#checkSignature
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
get _context () {
|
|
29
37
|
return this.#context
|
|
30
38
|
}
|
package/lib/bots/donothing.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import Bot from '../bot.js'
|
|
2
2
|
|
|
3
|
+
const DEFAULT_FULLNAME = 'Do Nothing Bot'
|
|
4
|
+
const DEFAULT_DESCRIPTION = 'A bot that does nothing.'
|
|
5
|
+
|
|
3
6
|
export default class DoNothingBot extends Bot {
|
|
7
|
+
#fullname
|
|
8
|
+
#description
|
|
9
|
+
|
|
10
|
+
constructor (username, options = {}) {
|
|
11
|
+
super(username, options)
|
|
12
|
+
this.#fullname = options.fullname || DEFAULT_FULLNAME
|
|
13
|
+
this.#description = options.description || DEFAULT_DESCRIPTION
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
get fullname () {
|
|
5
|
-
return
|
|
17
|
+
return this.#fullname
|
|
6
18
|
}
|
|
7
19
|
|
|
8
20
|
get description () {
|
|
9
|
-
return
|
|
21
|
+
return this.#description
|
|
10
22
|
}
|
|
11
23
|
}
|
package/lib/bots/followback.js
CHANGED
|
@@ -7,10 +7,10 @@ export default class FollowBackBot extends Bot {
|
|
|
7
7
|
#fullname
|
|
8
8
|
#description
|
|
9
9
|
|
|
10
|
-
constructor (username,
|
|
11
|
-
super(username)
|
|
12
|
-
this.#fullname = fullname
|
|
13
|
-
this.#description = description
|
|
10
|
+
constructor (username, options = {}) {
|
|
11
|
+
super(username, options)
|
|
12
|
+
this.#fullname = options.fullname || DEFAULT_NAME
|
|
13
|
+
this.#description = options.description || DEFAULT_DESCRIPTION
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
get fullname () {
|
package/lib/bots/relayclient.js
CHANGED
|
@@ -8,10 +8,10 @@ export default class RelayClientBot extends Bot {
|
|
|
8
8
|
#relay
|
|
9
9
|
#unsubscribe
|
|
10
10
|
|
|
11
|
-
constructor (username,
|
|
12
|
-
super(username)
|
|
13
|
-
this.#relay = relay
|
|
14
|
-
this.#unsubscribe = !!unsubscribe
|
|
11
|
+
constructor (username, options = {}) {
|
|
12
|
+
super(username, options)
|
|
13
|
+
this.#relay = options.relay
|
|
14
|
+
this.#unsubscribe = !!options.unsubscribe
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
get fullname () {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import createHttpError from 'http-errors'
|
|
2
|
+
import BotMaker from './botmaker.js'
|
|
2
3
|
|
|
3
4
|
export class HTTPSignatureAuthenticator {
|
|
4
5
|
static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
|
|
@@ -19,6 +20,26 @@ export class HTTPSignatureAuthenticator {
|
|
|
19
20
|
// Just continue
|
|
20
21
|
return next()
|
|
21
22
|
}
|
|
23
|
+
const { formatter, origin, bots } = req.app.locals
|
|
24
|
+
const originalUrl = req.originalUrl
|
|
25
|
+
const fullUrl = `${origin}${originalUrl}`
|
|
26
|
+
let parts
|
|
27
|
+
try {
|
|
28
|
+
parts = formatter.unformat(fullUrl)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
// do nothing
|
|
31
|
+
this.#logger.debug({ fullUrl, err }, 'Could not unformat')
|
|
32
|
+
}
|
|
33
|
+
if (parts && parts.username) {
|
|
34
|
+
this.#logger.debug({ username: parts.username }, 'Request for bot')
|
|
35
|
+
const bot = await BotMaker.makeBot(bots, parts.username)
|
|
36
|
+
if (!bot) {
|
|
37
|
+
this.#logger.warn({ username: parts.username }, 'no such bot')
|
|
38
|
+
} else if (!bot.checkSignature) {
|
|
39
|
+
this.#logger.debug({ username: parts.username }, 'bot says no sig')
|
|
40
|
+
return next()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
22
43
|
this.#logger.debug({ signature }, 'Got signed request')
|
|
23
44
|
const date = req.get('Date')
|
|
24
45
|
if (!date) {
|
|
@@ -45,7 +66,6 @@ export class HTTPSignatureAuthenticator {
|
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
const { method, headers } = req
|
|
48
|
-
const originalUrl = req.originalUrl
|
|
49
69
|
this.#logger.debug({ originalUrl }, 'original URL')
|
|
50
70
|
try {
|
|
51
71
|
const keyId = this.#signer.keyId(signature)
|
package/lib/keystorage.js
CHANGED
|
@@ -17,8 +17,8 @@ export class KeyStorage {
|
|
|
17
17
|
this.#hasher = new HumanHasher()
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async getPublicKey (username
|
|
21
|
-
assert.
|
|
20
|
+
async getPublicKey (username) {
|
|
21
|
+
assert.equal(typeof username, 'string')
|
|
22
22
|
assert.ok(username !== '*')
|
|
23
23
|
this.#logger.debug(
|
|
24
24
|
{ username, method: 'KeyStorage.getPublicKey' },
|
|
@@ -27,8 +27,8 @@ export class KeyStorage {
|
|
|
27
27
|
return publicKey
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
async getPrivateKey (username
|
|
31
|
-
assert.
|
|
30
|
+
async getPrivateKey (username) {
|
|
31
|
+
assert.equal(typeof username, 'string')
|
|
32
32
|
assert.ok(username !== '*')
|
|
33
33
|
this.#logger.debug(
|
|
34
34
|
{ username, method: 'KeyStorage.getPrivateKey' },
|
|
@@ -38,12 +38,10 @@ export class KeyStorage {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async #getKeys (username) {
|
|
41
|
+
assert.equal(typeof username, 'string')
|
|
42
|
+
assert.ok(username !== '*')
|
|
41
43
|
let privateKey
|
|
42
44
|
let publicKey
|
|
43
|
-
// system key uses username null but primary key can't be null
|
|
44
|
-
if (!username) {
|
|
45
|
-
username = ''
|
|
46
|
-
}
|
|
47
45
|
const [result] = await this.#connection.query(
|
|
48
46
|
'SELECT public_key, private_key FROM new_keys WHERE username = ?',
|
|
49
47
|
{ replacements: [username] }
|
package/lib/routes/server.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
|
-
import as2 from '../activitystreams.js'
|
|
3
2
|
|
|
4
3
|
const router = express.Router()
|
|
5
4
|
|
|
@@ -9,65 +8,4 @@ router.get('/', async (req, res) => {
|
|
|
9
8
|
res.sendFile(indexFileName)
|
|
10
9
|
})
|
|
11
10
|
|
|
12
|
-
router.get('/actor', async (req, res) => {
|
|
13
|
-
const { formatter, keyStorage, origin } = req.app.locals
|
|
14
|
-
const homepage = `${origin}/`
|
|
15
|
-
const publicKeyPem = await keyStorage.getPublicKey(null)
|
|
16
|
-
const acct = formatter.acct()
|
|
17
|
-
const webfinger = acct.slice(5)
|
|
18
|
-
const [username] = webfinger.split('@', 1)
|
|
19
|
-
const server = await as2.import({
|
|
20
|
-
'@context': [
|
|
21
|
-
'https://www.w3.org/ns/activitystreams',
|
|
22
|
-
'https://w3id.org/security/v1',
|
|
23
|
-
'https://purl.archive.org/socialweb/webfinger'
|
|
24
|
-
],
|
|
25
|
-
id: formatter.format({ server: true }),
|
|
26
|
-
type: 'Service',
|
|
27
|
-
to: 'as:Public',
|
|
28
|
-
name: username,
|
|
29
|
-
preferredUsername: username,
|
|
30
|
-
alsoKnownAs: acct,
|
|
31
|
-
webfinger,
|
|
32
|
-
publicKey: {
|
|
33
|
-
publicKeyPem,
|
|
34
|
-
id: formatter.format({ server: true, type: 'publickey' }),
|
|
35
|
-
owner: formatter.format({ server: true }),
|
|
36
|
-
type: 'CryptographicKey',
|
|
37
|
-
to: 'as:Public'
|
|
38
|
-
},
|
|
39
|
-
url: {
|
|
40
|
-
type: 'Link',
|
|
41
|
-
mediaType: 'text/html',
|
|
42
|
-
href: homepage
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
const body = await server.export({ useOriginalContext: true })
|
|
46
|
-
res.status(200)
|
|
47
|
-
res.type(as2.mediaType)
|
|
48
|
-
res.json(body)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
router.get('/publickey', async (req, res) => {
|
|
52
|
-
const { formatter, keyStorage } = req.app.locals
|
|
53
|
-
const publicKeyPem = await keyStorage.getPublicKey(null)
|
|
54
|
-
const publicKey = await as2.import({
|
|
55
|
-
'@context': [
|
|
56
|
-
'https://www.w3.org/ns/activitystreams',
|
|
57
|
-
'https://w3id.org/security/v1'
|
|
58
|
-
],
|
|
59
|
-
publicKeyPem,
|
|
60
|
-
id: formatter.format({ server: true, type: 'publickey' }),
|
|
61
|
-
owner: formatter.format({ server: true }),
|
|
62
|
-
type: 'CryptographicKey',
|
|
63
|
-
to: 'as:Public'
|
|
64
|
-
})
|
|
65
|
-
res.status(200)
|
|
66
|
-
res.type(as2.mediaType)
|
|
67
|
-
const body = await publicKey.prettyWrite(
|
|
68
|
-
{ additional_context: 'https://w3id.org/security/v1' }
|
|
69
|
-
)
|
|
70
|
-
res.end(body)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
11
|
export default router
|
package/lib/routes/webfinger.js
CHANGED
|
@@ -28,24 +28,6 @@ async function botWebfinger (username, req, res, next) {
|
|
|
28
28
|
})
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function serverWebfinger (req, res, next) {
|
|
32
|
-
const { formatter } = req.app.locals
|
|
33
|
-
const webfinger = formatter.acct()
|
|
34
|
-
const id = formatter.format({ server: true })
|
|
35
|
-
res.status(200)
|
|
36
|
-
res.type('application/jrd+json')
|
|
37
|
-
res.json({
|
|
38
|
-
subject: webfinger,
|
|
39
|
-
links: [
|
|
40
|
-
{
|
|
41
|
-
rel: 'self',
|
|
42
|
-
type: 'application/activity+json',
|
|
43
|
-
href: id
|
|
44
|
-
}
|
|
45
|
-
]
|
|
46
|
-
})
|
|
47
|
-
}
|
|
48
|
-
|
|
49
31
|
async function httpsWebfinger (resource, req, res, next) {
|
|
50
32
|
const { formatter } = req.app.locals
|
|
51
33
|
assert.ok(formatter)
|
|
@@ -53,11 +35,7 @@ async function httpsWebfinger (resource, req, res, next) {
|
|
|
53
35
|
return next(createHttpError(400, 'Only local URLs'))
|
|
54
36
|
}
|
|
55
37
|
const parts = formatter.unformat(resource)
|
|
56
|
-
if (parts.
|
|
57
|
-
return await serverWebfinger(req, res, next)
|
|
58
|
-
} else if (
|
|
59
|
-
!parts.server && parts.username && !parts.type && !parts.collection
|
|
60
|
-
) {
|
|
38
|
+
if (parts.username && !parts.type && !parts.collection) {
|
|
61
39
|
return await botWebfinger(parts.username, req, res, next)
|
|
62
40
|
} else {
|
|
63
41
|
return next(createHttpError(400, `No webfinger lookup for url ${resource}`))
|
|
@@ -73,11 +51,7 @@ async function acctWebfinger (resource, req, res, next) {
|
|
|
73
51
|
if (domain !== host) {
|
|
74
52
|
return next(createHttpError(400, `Invalid domain ${domain} in resource parameter`))
|
|
75
53
|
}
|
|
76
|
-
|
|
77
|
-
return await serverWebfinger(req, res, next)
|
|
78
|
-
} else {
|
|
79
|
-
return await botWebfinger(username, req, res, next)
|
|
80
|
-
}
|
|
54
|
+
return await botWebfinger(username, req, res, next)
|
|
81
55
|
}
|
|
82
56
|
|
|
83
57
|
router.get('/.well-known/webfinger', async (req, res, next) => {
|
package/lib/urlformatter.js
CHANGED
|
@@ -14,7 +14,7 @@ export class UrlFormatter {
|
|
|
14
14
|
format ({ username, type, nanoid, collection, page, server }) {
|
|
15
15
|
let base = null
|
|
16
16
|
if (server) {
|
|
17
|
-
|
|
17
|
+
return this.format({ username: this.#hostname, type, nanoid, collection, page, server: false })
|
|
18
18
|
} else if (username) {
|
|
19
19
|
base = `${this.#origin}/user/${username}`
|
|
20
20
|
} else {
|
|
@@ -69,16 +69,14 @@ export class UrlFormatter {
|
|
|
69
69
|
}
|
|
70
70
|
let pathParts = parsed.pathname.slice(1).split('/')
|
|
71
71
|
if (pathParts.length > 0 && pathParts[0] === 'user') {
|
|
72
|
-
parts.server = false
|
|
73
72
|
parts.username = pathParts[1]
|
|
74
73
|
pathParts = pathParts.slice(2)
|
|
75
|
-
|
|
76
|
-
parts.server = true
|
|
74
|
+
parts.server = (parts.username === this.#hostname)
|
|
77
75
|
}
|
|
78
76
|
if (pathParts.length > 0) {
|
|
79
77
|
if (USER_COLLECTIONS.includes(pathParts[0])) {
|
|
80
78
|
parts.collection = pathParts[0]
|
|
81
|
-
} else
|
|
79
|
+
} else {
|
|
82
80
|
parts.type = pathParts[0]
|
|
83
81
|
}
|
|
84
82
|
}
|
|
@@ -111,4 +109,8 @@ export class UrlFormatter {
|
|
|
111
109
|
? `acct:${username}@${this.#hostname}`
|
|
112
110
|
: `acct:${this.#hostname}@${this.#hostname}`
|
|
113
111
|
}
|
|
112
|
+
|
|
113
|
+
get hostname () {
|
|
114
|
+
return this.#hostname
|
|
115
|
+
}
|
|
114
116
|
}
|