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