@evanp/activitypub-bot 0.30.5 → 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 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.
@@ -50,10 +50,12 @@ export class ActivityPubClient {
50
50
  this.#limiter = limiter
51
51
  }
52
52
 
53
- async get (url, username = null) {
53
+ async get (url, username = this.#urlFormatter.hostname) {
54
54
  assert.ok(url)
55
55
  assert.equal(typeof url, 'string')
56
- assert.ok(username === null || 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, null, false)
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, null, true)
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 = null, sign = false) {
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 = null) {
195
+ async * items (id, username = this.#urlFormatter.hostname) {
193
196
  assert.ok(id)
194
197
  assert.equal(typeof id, 'string')
195
- assert.ok(
196
- username === null ||
197
- (typeof username === 'string' && username !== '*')
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
 
@@ -188,15 +208,20 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
188
208
  }
189
209
  }))
190
210
 
191
- app.use(signature.authenticate.bind(signature))
211
+ // These should not check HTTP Signature even if provided
192
212
 
193
213
  app.use('/', serverRouter)
214
+ app.use('/', healthRouter)
215
+ app.use('/', webfingerRouter)
216
+
217
+ // These should check HTTP Signature if provided
218
+
219
+ app.use(signature.authenticate.bind(signature))
220
+
194
221
  app.use('/', userRouter)
195
222
  app.use('/', collectionRouter)
196
223
  app.use('/', inboxRouter)
197
224
  app.use('/', objectRouter)
198
- app.use('/', healthRouter)
199
- app.use('/', webfingerRouter)
200
225
  app.use('/', sharedInboxRouter)
201
226
 
202
227
  app.use(async (req, res) => {
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
  }
@@ -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 'Do Nothing Bot'
17
+ return this.#fullname
6
18
  }
7
19
 
8
20
  get description () {
9
- return 'A bot that does nothing.'
21
+ return this.#description
10
22
  }
11
23
  }
@@ -7,10 +7,10 @@ export default class FollowBackBot extends Bot {
7
7
  #fullname
8
8
  #description
9
9
 
10
- constructor (username, { fullname = DEFAULT_NAME, description = DEFAULT_DESCRIPTION } = {}) {
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 () {
@@ -8,10 +8,10 @@ export default class RelayClientBot extends Bot {
8
8
  #relay
9
9
  #unsubscribe
10
10
 
11
- constructor (username, relay, unsubscribe = null) {
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 = null) {
21
- assert.ok(username === null || typeof username === 'string')
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 = null) {
31
- assert.ok(username === null || typeof username === 'string')
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] }
@@ -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
@@ -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.server && !parts.type) {
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
- if (username === domain) {
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) => {
@@ -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
- base = `${this.#origin}`
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
- } else {
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 if (pathParts[0] !== 'actor') {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.30.5",
3
+ "version": "0.31.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",