@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,69 @@
|
|
|
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
|
+
export default router
|
|
8
|
+
|
|
9
|
+
router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})', async (req, res, next) => {
|
|
10
|
+
const { username, type, nanoid } = req.params
|
|
11
|
+
const { objectStorage, formatter, authorizer } = req.app.locals
|
|
12
|
+
const id = formatter.format({ username, type, nanoid })
|
|
13
|
+
const object = await objectStorage.read(id)
|
|
14
|
+
if (!object) {
|
|
15
|
+
return next(createHttpError(404, `Object ${id} not found`))
|
|
16
|
+
}
|
|
17
|
+
const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
|
|
18
|
+
if (!await authorizer.canRead(remote, object)) {
|
|
19
|
+
return next(createHttpError(403, `Forbidden to read object ${id}`))
|
|
20
|
+
}
|
|
21
|
+
res.status(200)
|
|
22
|
+
res.type(as2.mediaType)
|
|
23
|
+
res.end(await object.prettyWrite())
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection', async (req, res, next) => {
|
|
27
|
+
const { objectStorage, formatter, authorizer } = req.app.locals
|
|
28
|
+
if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
|
|
29
|
+
return next(createHttpError(404, 'Not Found'))
|
|
30
|
+
}
|
|
31
|
+
const id = formatter.format({ username: req.params.username, type: req.params.type, nanoid: req.params.nanoid })
|
|
32
|
+
const object = await objectStorage.read(id)
|
|
33
|
+
if (!object) {
|
|
34
|
+
return next(createHttpError(404, 'Not Found'))
|
|
35
|
+
}
|
|
36
|
+
const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
|
|
37
|
+
if (!await authorizer.canRead(remote, object)) {
|
|
38
|
+
return next(createHttpError(403, 'Forbidden'))
|
|
39
|
+
}
|
|
40
|
+
const collection = await objectStorage.getCollection(id, req.params.collection)
|
|
41
|
+
res.status(200)
|
|
42
|
+
res.type(as2.mediaType)
|
|
43
|
+
res.end(await collection.prettyWrite({ useOriginalContext: true }))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
router.get('/user/:username/:type/:nanoid([A-Za-z0-9_\\-]{21})/:collection/:n(\\d+)', async (req, res, next) => {
|
|
47
|
+
const { username, type, nanoid, collection, n } = req.params
|
|
48
|
+
const { objectStorage, formatter, authorizer } = req.app.locals
|
|
49
|
+
if (!['replies', 'likes', 'shares', 'thread'].includes(req.params.collection)) {
|
|
50
|
+
return next(createHttpError(404, 'Not Found'))
|
|
51
|
+
}
|
|
52
|
+
const id = formatter.format({ username, type, nanoid })
|
|
53
|
+
const object = await objectStorage.read(id)
|
|
54
|
+
if (!object) {
|
|
55
|
+
return next(createHttpError(404, 'Not Found'))
|
|
56
|
+
}
|
|
57
|
+
const remote = (req.auth?.subject) ? await as2.import({ id: req.auth.subject }) : null
|
|
58
|
+
if (!await authorizer.canRead(remote, object)) {
|
|
59
|
+
return next(createHttpError(403, 'Forbidden'))
|
|
60
|
+
}
|
|
61
|
+
const collectionPage = await objectStorage.getCollectionPage(id, collection, parseInt(n))
|
|
62
|
+
const exported = await collectionPage.export({ useOriginalContext: true })
|
|
63
|
+
if (!Array.isArray(exported.items)) {
|
|
64
|
+
exported.items = [exported.items]
|
|
65
|
+
}
|
|
66
|
+
res.status(200)
|
|
67
|
+
res.type(as2.mediaType)
|
|
68
|
+
res.json(exported)
|
|
69
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import as2 from '../activitystreams.js'
|
|
3
|
+
|
|
4
|
+
const router = express.Router()
|
|
5
|
+
|
|
6
|
+
router.get('/', async (req, res) => {
|
|
7
|
+
const { formatter } = req.app.locals
|
|
8
|
+
const server = await as2.import({
|
|
9
|
+
'@context': [
|
|
10
|
+
'https://www.w3.org/ns/activitystreams',
|
|
11
|
+
'https://w3id.org/security/v1'
|
|
12
|
+
],
|
|
13
|
+
id: formatter.format({ server: true }),
|
|
14
|
+
type: 'Service',
|
|
15
|
+
publicKey: formatter.format({ server: true, type: 'publickey' })
|
|
16
|
+
})
|
|
17
|
+
res.status(200)
|
|
18
|
+
res.type(as2.mediaType)
|
|
19
|
+
const body = await server.prettyWrite(
|
|
20
|
+
{ additional_context: 'https://w3id.org/security/v1' }
|
|
21
|
+
)
|
|
22
|
+
res.end(body)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
router.get('/publickey', async (req, res) => {
|
|
26
|
+
const { formatter, keyStorage } = req.app.locals
|
|
27
|
+
const publicKeyPem = await keyStorage.getPublicKey(null)
|
|
28
|
+
const publicKey = await as2.import({
|
|
29
|
+
'@context': [
|
|
30
|
+
'https://www.w3.org/ns/activitystreams',
|
|
31
|
+
'https://w3id.org/security/v1'
|
|
32
|
+
],
|
|
33
|
+
publicKeyPem,
|
|
34
|
+
id: formatter.format({ server: true, type: 'publickey' }),
|
|
35
|
+
owner: formatter.format({ server: true }),
|
|
36
|
+
type: 'CryptographicKey',
|
|
37
|
+
to: 'https://www.w3.org/ns/activitystreams#Public'
|
|
38
|
+
})
|
|
39
|
+
res.status(200)
|
|
40
|
+
res.type(as2.mediaType)
|
|
41
|
+
const body = await publicKey.prettyWrite(
|
|
42
|
+
{ additional_context: 'https://w3id.org/security/v1' }
|
|
43
|
+
)
|
|
44
|
+
res.end(body)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export default router
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
router.get('/user/:username', async (req, res, next) => {
|
|
8
|
+
const { username } = req.params
|
|
9
|
+
const { actorStorage, keyStorage, formatter, bots } = req.app.locals
|
|
10
|
+
if (!(username in bots)) {
|
|
11
|
+
return next(createHttpError(404, `User ${username} not found`))
|
|
12
|
+
}
|
|
13
|
+
const publicKeyPem = await keyStorage.getPublicKey(username)
|
|
14
|
+
const actor = await actorStorage.getActor(username, {
|
|
15
|
+
'@context': [
|
|
16
|
+
'https://www.w3.org/ns/activitystreams',
|
|
17
|
+
'https://w3id.org/security/v1'
|
|
18
|
+
],
|
|
19
|
+
name: bots[username].fullname,
|
|
20
|
+
summary: bots[username].description,
|
|
21
|
+
publicKey: {
|
|
22
|
+
publicKeyPem,
|
|
23
|
+
id: formatter.format({ username, type: 'publickey' }),
|
|
24
|
+
owner: formatter.format({ username }),
|
|
25
|
+
type: 'CryptographicKey',
|
|
26
|
+
to: 'as:Public'
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
res.status(200)
|
|
30
|
+
res.type(as2.mediaType)
|
|
31
|
+
const body = await actor.prettyWrite(
|
|
32
|
+
{ additional_context: 'https://w3id.org/security/v1' }
|
|
33
|
+
)
|
|
34
|
+
res.end(body)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
router.get('/user/:username/publickey', async (req, res, next) => {
|
|
38
|
+
const { username } = req.params
|
|
39
|
+
const { formatter, keyStorage, bots } = req.app.locals
|
|
40
|
+
if (!(username in bots)) {
|
|
41
|
+
return next(createHttpError(404, `User ${username} not found`))
|
|
42
|
+
}
|
|
43
|
+
const publicKeyPem = await keyStorage.getPublicKey(username)
|
|
44
|
+
const publicKey = await as2.import({
|
|
45
|
+
'@context': [
|
|
46
|
+
'https://www.w3.org/ns/activitystreams',
|
|
47
|
+
'https://w3id.org/security/v1'
|
|
48
|
+
],
|
|
49
|
+
publicKeyPem,
|
|
50
|
+
id: formatter.format({ username, type: 'publickey' }),
|
|
51
|
+
owner: formatter.format({ username }),
|
|
52
|
+
type: 'CryptographicKey',
|
|
53
|
+
to: 'as:Public'
|
|
54
|
+
})
|
|
55
|
+
res.status(200)
|
|
56
|
+
res.type(as2.mediaType)
|
|
57
|
+
const body = await publicKey.prettyWrite(
|
|
58
|
+
{ additional_context: 'https://w3id.org/security/v1' }
|
|
59
|
+
)
|
|
60
|
+
res.end(body)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export default router
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import createHttpError from 'http-errors'
|
|
3
|
+
|
|
4
|
+
const router = Router()
|
|
5
|
+
|
|
6
|
+
router.get('/.well-known/webfinger', (req, res, next) => {
|
|
7
|
+
const { resource } = req.query
|
|
8
|
+
if (!resource) {
|
|
9
|
+
return next(createHttpError(400, 'resource parameter is required'))
|
|
10
|
+
}
|
|
11
|
+
const [username, domain] = resource.substring(5).split('@')
|
|
12
|
+
if (!username || !domain) {
|
|
13
|
+
return next(createHttpError(400, 'Invalid resource parameter'))
|
|
14
|
+
}
|
|
15
|
+
const { host } = new URL(req.app.locals.origin)
|
|
16
|
+
if (domain !== host) {
|
|
17
|
+
return next(createHttpError(400, 'Invalid domain in resource parameter'))
|
|
18
|
+
}
|
|
19
|
+
if (!(username in req.app.locals.bots)) {
|
|
20
|
+
return next(createHttpError(404, 'Bot not found'))
|
|
21
|
+
}
|
|
22
|
+
res.status(200)
|
|
23
|
+
res.type('application/jrd+json')
|
|
24
|
+
res.json({
|
|
25
|
+
subject: resource,
|
|
26
|
+
links: [
|
|
27
|
+
{
|
|
28
|
+
rel: 'self',
|
|
29
|
+
type: 'application/activity+json',
|
|
30
|
+
href: req.app.locals.formatter.format({ username })
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export default router
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
|
|
3
|
+
const USER_COLLECTIONS = ['inbox', 'outbox', 'liked', 'followers', 'following']
|
|
4
|
+
|
|
5
|
+
export class UrlFormatter {
|
|
6
|
+
#origin = null
|
|
7
|
+
constructor (origin) {
|
|
8
|
+
this.#origin = origin
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
format ({ username, type, nanoid, collection, page, server }) {
|
|
12
|
+
let base = null
|
|
13
|
+
if (server) {
|
|
14
|
+
base = `${this.#origin}`
|
|
15
|
+
} else if (username) {
|
|
16
|
+
base = `${this.#origin}/user/${username}`
|
|
17
|
+
} else {
|
|
18
|
+
throw new Error('Cannot format URL without username or server')
|
|
19
|
+
}
|
|
20
|
+
let major = null
|
|
21
|
+
if (type) {
|
|
22
|
+
if (nanoid) {
|
|
23
|
+
major = `${base}/${type}/${nanoid}`
|
|
24
|
+
} else if (type === 'publickey') {
|
|
25
|
+
major = `${base}/${type}`
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error('Cannot format URL without nanoid')
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
major = base
|
|
31
|
+
}
|
|
32
|
+
let url = null
|
|
33
|
+
if (collection) {
|
|
34
|
+
if (page) {
|
|
35
|
+
url = `${major}/${collection}/${page}`
|
|
36
|
+
} else {
|
|
37
|
+
url = `${major}/${collection}`
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
url = major
|
|
41
|
+
}
|
|
42
|
+
// For the base case, we want a trailing slash.
|
|
43
|
+
if (url === this.#origin) {
|
|
44
|
+
url = `${url}/`
|
|
45
|
+
}
|
|
46
|
+
return url
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
isLocal (url) {
|
|
50
|
+
assert.equal(typeof url, 'string', 'url must be a string')
|
|
51
|
+
return url.startsWith(this.#origin)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getUserName (url) {
|
|
55
|
+
assert.equal(typeof url, 'string', 'url must be a string')
|
|
56
|
+
const parts = this.unformat(url)
|
|
57
|
+
return parts.username
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
unformat (url) {
|
|
61
|
+
assert.equal(typeof url, 'string', 'url must be a string')
|
|
62
|
+
const parts = {}
|
|
63
|
+
const parsed = new URL(url)
|
|
64
|
+
if (parsed.origin !== this.#origin) {
|
|
65
|
+
throw new Error(`Can't unformat URL from remote server ${parsed.origin}`)
|
|
66
|
+
}
|
|
67
|
+
let pathParts = parsed.pathname.slice(1).split('/')
|
|
68
|
+
if (pathParts.length > 0 && pathParts[0] === 'user') {
|
|
69
|
+
parts.server = false
|
|
70
|
+
parts.username = pathParts[1]
|
|
71
|
+
pathParts = pathParts.slice(2)
|
|
72
|
+
} else {
|
|
73
|
+
parts.server = true
|
|
74
|
+
}
|
|
75
|
+
if (pathParts.length > 0) {
|
|
76
|
+
if (USER_COLLECTIONS.includes(pathParts[0])) {
|
|
77
|
+
parts.collection = pathParts[0]
|
|
78
|
+
} else {
|
|
79
|
+
parts.type = pathParts[0]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (pathParts.length > 1) {
|
|
83
|
+
if (USER_COLLECTIONS.includes(pathParts[0])) {
|
|
84
|
+
parts.page = parseInt(pathParts[1])
|
|
85
|
+
} else {
|
|
86
|
+
parts.nanoid = pathParts[1]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (pathParts.length > 2) {
|
|
90
|
+
parts.collection = pathParts[2]
|
|
91
|
+
}
|
|
92
|
+
if (pathParts.length > 3) {
|
|
93
|
+
parts.page = parseInt(pathParts[3])
|
|
94
|
+
}
|
|
95
|
+
return parts
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evanp/activitypub-bot",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "server-side ActivityPub bot framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "NODE_ENV=test node --test",
|
|
9
|
+
"start": "node index.js"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+ssh://git@github.com/evanp/activitypub-bot.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"activitypub",
|
|
17
|
+
"fediverse",
|
|
18
|
+
"bots",
|
|
19
|
+
"server"
|
|
20
|
+
],
|
|
21
|
+
"author": "Evan Prodromou <evan@prodromou.name>",
|
|
22
|
+
"license": "AGPL-3.0",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/evanp/activitypub-bot/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/evanp/activitypub-bot#readme",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@isaacs/ttlcache": "^1.4.1",
|
|
29
|
+
"activitystrea.ms": "3.2",
|
|
30
|
+
"express": "^4.18.2",
|
|
31
|
+
"http-errors": "^2.0.0",
|
|
32
|
+
"humanhash": "^1.0.4",
|
|
33
|
+
"lru-cache": "^11.1.0",
|
|
34
|
+
"nanoid": "^5.1.5",
|
|
35
|
+
"node-fetch": "^3.3.2",
|
|
36
|
+
"p-queue": "^8.1.0",
|
|
37
|
+
"pino": "^9.7.0",
|
|
38
|
+
"pino-http": "^10.4.0",
|
|
39
|
+
"sequelize": "^6.37.7"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"nock": "^14.0.5",
|
|
43
|
+
"standard": "^17.1.2",
|
|
44
|
+
"supertest": "^7.1.1"
|
|
45
|
+
},
|
|
46
|
+
"optionalDependencies": {
|
|
47
|
+
"mysql2": "^3.9.1",
|
|
48
|
+
"pg": "^8.16.0",
|
|
49
|
+
"sqlite3": "^5.1.7"
|
|
50
|
+
}
|
|
51
|
+
}
|