@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,87 @@
1
+ import assert from 'node:assert'
2
+
3
+ export class NoSuchValueError extends Error {
4
+ constructor (username, key) {
5
+ const message = `No such value ${key} for user ${username}`
6
+ super(message)
7
+ this.name = 'NoSuchValueError'
8
+ }
9
+ }
10
+
11
+ export class BotDataStorage {
12
+ #connection = null
13
+
14
+ constructor (connection) {
15
+ this.#connection = connection
16
+ }
17
+
18
+ async initialize () {
19
+ await this.#connection.query(`
20
+ CREATE TABLE IF NOT EXISTS botdata (
21
+ username VARCHAR(512) not null,
22
+ key VARCHAR(512) not null,
23
+ value TEXT not null,
24
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+ PRIMARY KEY (username, key)
27
+ )
28
+ `)
29
+ }
30
+
31
+ async terminate () {
32
+
33
+ }
34
+
35
+ async set (username, key, value) {
36
+ assert.ok(this.#connection, 'BotDataStorage not initialized')
37
+ assert.ok(username, 'username is required')
38
+ assert.equal(typeof username, 'string', 'username must be a string')
39
+ assert.ok(key, 'key is required')
40
+ assert.equal(typeof key, 'string', 'key must be a string')
41
+ await this.#connection.query(`
42
+ INSERT INTO botdata (value, username, key) VALUES (?, ?, ?)
43
+ ON CONFLICT DO UPDATE SET value = EXCLUDED.value, updatedAt = CURRENT_TIMESTAMP`,
44
+ { replacements: [JSON.stringify(value), username, key] }
45
+ )
46
+ }
47
+
48
+ async get (username, key) {
49
+ assert.ok(this.#connection, 'BotDataStorage not initialized')
50
+ assert.ok(username, 'username is required')
51
+ assert.equal(typeof username, 'string', 'username must be a string')
52
+ assert.ok(key, 'key is required')
53
+ assert.equal(typeof key, 'string', 'key must be a string')
54
+ const rows = await this.#connection.query(`
55
+ SELECT value FROM botdata WHERE username = ? AND key = ?`,
56
+ { replacements: [username, key] }
57
+ )
58
+ if (rows[0].length === 0) {
59
+ throw new NoSuchValueError(username, key)
60
+ }
61
+ return JSON.parse(rows[0][0].value)
62
+ }
63
+
64
+ async has (username, key) {
65
+ assert.ok(this.#connection, 'BotDataStorage not initialized')
66
+ assert.ok(username, 'username is required')
67
+ assert.equal(typeof username, 'string', 'username must be a string')
68
+ assert.ok(key, 'key is required')
69
+ assert.equal(typeof key, 'string', 'key must be a string')
70
+ const rows = await this.#connection.query(`
71
+ SELECT count(*) as count FROM botdata WHERE username = ? AND key = ?`,
72
+ { replacements: [username, key] }
73
+ )
74
+ return (rows[0][0].count > 0)
75
+ }
76
+
77
+ async delete (username, key) {
78
+ assert.ok(this.#connection, 'BotDataStorage not initialized')
79
+ assert.ok(username, 'username is required')
80
+ assert.equal(typeof username, 'string', 'username must be a string')
81
+ assert.ok(key, 'key is required')
82
+ await this.#connection.query(`
83
+ DELETE FROM botdata WHERE username = ? AND key = ?`,
84
+ { replacements: [username, key] }
85
+ )
86
+ }
87
+ }
@@ -0,0 +1,11 @@
1
+ import Bot from '../bot.js'
2
+
3
+ export default class DoNothingBot extends Bot {
4
+ get fullname () {
5
+ return 'Do Nothing Bot'
6
+ }
7
+
8
+ get description () {
9
+ return 'A bot that does nothing.'
10
+ }
11
+ }
package/lib/bots/ok.js ADDED
@@ -0,0 +1,41 @@
1
+ import Bot from '../bot.js'
2
+
3
+ export default class OKBot extends Bot {
4
+ get fullname () {
5
+ return 'OK Bot'
6
+ }
7
+
8
+ get description () {
9
+ return 'A bot that says "OK" when mentioned.'
10
+ }
11
+
12
+ async onMention (object, activity) {
13
+ if (!await this.hasSeen(object)) {
14
+ const attributedTo =
15
+ object.attributedTo?.first.id ||
16
+ activity.actor?.first.id
17
+ const wf = await this._context.toWebfinger(attributedTo)
18
+ this._context.logger.info({ object: object.id, attributedTo, wf }, 'received mention')
19
+ const content = (wf) ? `@${wf} OK` : 'OK'
20
+ const reply = await this._context.sendReply(content, object)
21
+ this._context.logger.info({
22
+ reply: reply.id,
23
+ content,
24
+ inReplyTo: reply.inReplyTo.id
25
+ }, 'sent reply')
26
+ await this.setSeen(object)
27
+ }
28
+ }
29
+
30
+ async hasSeen (object) {
31
+ const id = object.id
32
+ const key = `seen:${id}`
33
+ return this._context.hasData(key)
34
+ }
35
+
36
+ async setSeen (object) {
37
+ const id = object.id
38
+ const key = `seen:${id}`
39
+ return this._context.setData(key, true)
40
+ }
41
+ }
@@ -0,0 +1,23 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export class Digester {
4
+ #logger
5
+ constructor (logger) {
6
+ this.#logger = logger.child({ class: this.constructor.name })
7
+ }
8
+
9
+ async digest (body) {
10
+ const digest = crypto.createHash('sha256')
11
+ digest.update(body)
12
+ return `sha-256=${digest.digest('base64')}`
13
+ }
14
+
15
+ equals (digest1, digest2) {
16
+ const [alg1, hash1] = digest1.split('=', 2)
17
+ const [alg2, hash2] = digest2.split('=', 2)
18
+ if (alg1.toLowerCase() !== alg2.toLowerCase()) {
19
+ return false
20
+ }
21
+ return hash1 === hash2
22
+ }
23
+ }
@@ -0,0 +1,195 @@
1
+ import createHttpError from 'http-errors'
2
+ import crypto from 'node:crypto'
3
+ import assert from 'node:assert'
4
+
5
+ export class HTTPSignature {
6
+ static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
7
+ #logger = null
8
+ constructor (logger) {
9
+ this.#logger = logger.child({ class: this.constructor.name })
10
+ }
11
+
12
+ keyId (signature) {
13
+ const params = this.#parseSignatureHeader(signature)
14
+ if (!params.keyId) {
15
+ throw createHttpError(401, 'No keyId provided')
16
+ }
17
+ return params.keyId
18
+ }
19
+
20
+ async validate (publicKeyPem, signature, method, path, headers) {
21
+ if (!signature) {
22
+ throw createHttpError(401, 'No signature provided')
23
+ }
24
+ if (!method) {
25
+ throw createHttpError(400, 'No HTTP method provided')
26
+ }
27
+ if (!path) {
28
+ throw createHttpError(400, 'No URL path provided')
29
+ }
30
+ if (!headers) {
31
+ throw createHttpError(400, 'No request headers provided')
32
+ }
33
+
34
+ const params = this.#parseSignatureHeader(signature)
35
+
36
+ const keyId = params.keyId
37
+ if (!keyId) {
38
+ throw createHttpError(401, 'No keyId provided')
39
+ }
40
+ this.#logger.debug({ keyId }, 'validating signature')
41
+ const algorithm = params.algorithm
42
+ if (!algorithm) {
43
+ throw createHttpError(401, 'No algorithm provided')
44
+ }
45
+ this.#logger.debug({ algorithm }, 'validating signature')
46
+ if (algorithm !== 'rsa-sha256') {
47
+ throw createHttpError(401, 'Only rsa-sha256 is supported')
48
+ }
49
+ if (!params.headers) {
50
+ throw createHttpError(401, 'No headers provided')
51
+ }
52
+ const signedHeaders = params.headers.split(' ')
53
+ this.#logger.debug({ signedHeaders }, 'validating signature')
54
+ const signatureString = params.signature
55
+ if (!signatureString) {
56
+ throw createHttpError(401, 'No signature field provided in signature header')
57
+ }
58
+ this.#logger.debug({ signatureString }, 'validating signature')
59
+ const signingString = this.#signingString({
60
+ method,
61
+ target: path,
62
+ host: headers.host,
63
+ headers,
64
+ headersList: signedHeaders
65
+ })
66
+ this.#logger.debug({ signingString }, 'validating signature')
67
+ return this.#verify(publicKeyPem, signingString, signatureString)
68
+ }
69
+
70
+ async sign ({ privateKey, keyId, url, method, headers }) {
71
+ assert.ok(privateKey)
72
+ assert.equal(typeof privateKey, 'string')
73
+ assert.ok(keyId)
74
+ assert.equal(typeof keyId, 'string')
75
+ assert.ok(url)
76
+ assert.equal(typeof url, 'string')
77
+ assert.ok(method)
78
+ assert.equal(typeof method, 'string')
79
+ assert.ok(headers)
80
+ assert.equal(typeof headers, 'object')
81
+
82
+ this.#logger.debug({ keyId, url, method, headers }, 'signing a request')
83
+
84
+ const algorithm = 'rsa-sha256'
85
+ const headersList = (method === 'POST')
86
+ ? ['(request-target)', 'host', 'date', 'user-agent', 'content-type', 'digest']
87
+ : ['(request-target)', 'host', 'date', 'user-agent', 'accept']
88
+
89
+ this.#logger.debug({ algorithm, headersList }, 'signing a request')
90
+
91
+ const parsed = new URL(url)
92
+ const target = (parsed.search && parsed.search.length)
93
+ ? `${parsed.pathname}${parsed.search}`
94
+ : `${parsed.pathname}`
95
+ const host = parsed.host
96
+
97
+ this.#logger.debug({ parsed, target, host }, 'signing a request')
98
+
99
+ const signingString = this.#signingString({
100
+ method,
101
+ host,
102
+ target,
103
+ headers,
104
+ headersList
105
+ })
106
+
107
+ this.#logger.debug({ signingString }, 'signing a request')
108
+
109
+ const signature = this.#signWithKey({
110
+ privateKey,
111
+ signingString,
112
+ algorithm
113
+ })
114
+
115
+ this.#logger.debug({ signature }, 'signed a request')
116
+
117
+ const signatureHeader = this.#signatureHeader({ keyId, headersList, signature, algorithm })
118
+
119
+ this.#logger.debug({ signatureHeader }, 'signed a request')
120
+ return signatureHeader
121
+ }
122
+
123
+ #signWithKey ({ privateKey, signingString, algorithm }) {
124
+ if (algorithm !== 'rsa-sha256') {
125
+ throw new Error('Only rsa-sha256 is supported')
126
+ }
127
+ const signer = crypto.createSign('sha256')
128
+ signer.update(signingString)
129
+ const signature = signer.sign(privateKey).toString('base64')
130
+ signer.end()
131
+
132
+ return signature
133
+ }
134
+
135
+ #signingString ({ method, host, target, headers, headersList }) {
136
+ const lines = []
137
+ const canon = {}
138
+ for (const key in headers) {
139
+ canon[key.toLowerCase()] = headers[key]
140
+ }
141
+ for (const headerName of headersList) {
142
+ if (headerName === '(request-target)') {
143
+ lines.push(`(request-target): ${method.toLowerCase()} ${target.trim()}`)
144
+ } else if (headerName === 'host') {
145
+ lines.push(`host: ${host.trim()}`)
146
+ } else if (headerName in canon) {
147
+ assert.ok(typeof canon[headerName] === 'string', `Header ${headerName} is not a string: ${canon[headerName]}`)
148
+ lines.push(`${headerName}: ${canon[headerName].trim()}`)
149
+ } else {
150
+ throw new Error(`Missing header: ${headerName}`)
151
+ }
152
+ }
153
+
154
+ return lines.join('\n')
155
+ }
156
+
157
+ #signatureHeader ({ keyId, headersList, signature, algorithm }) {
158
+ const components = {
159
+ keyId,
160
+ headers: headersList.join(' '),
161
+ signature,
162
+ algorithm
163
+ }
164
+ const properties = ['keyId', 'headers', 'signature', 'algorithm']
165
+
166
+ const pairs = []
167
+ for (const prop of properties) {
168
+ pairs.push(`${prop}="${this.#escape(components[prop])}"`)
169
+ }
170
+
171
+ return pairs.join(',')
172
+ }
173
+
174
+ #escape (value) {
175
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
176
+ }
177
+
178
+ #parseSignatureHeader (signature) {
179
+ const parts = signature.split(',')
180
+ const params = {}
181
+ for (const part of parts) {
182
+ const [key, value] = part.split('=')
183
+ params[key] = value.replace(/"/g, '')
184
+ }
185
+ return params
186
+ }
187
+
188
+ #verify (publicKeyPem, signingString, signature) {
189
+ const verifier = crypto.createVerify('sha256')
190
+ verifier.update(signingString)
191
+ const isValid = verifier.verify(publicKeyPem, signature, 'base64')
192
+ verifier.end()
193
+ return isValid
194
+ }
195
+ }
@@ -0,0 +1,81 @@
1
+ import createHttpError from 'http-errors'
2
+
3
+ export class HTTPSignatureAuthenticator {
4
+ static #maxDateDiff = 5 * 60 * 1000 // 5 minutes
5
+ #remoteKeyStorage = null
6
+ #logger = null
7
+ #digester = null
8
+ #signer = null
9
+ constructor (remoteKeyStorage, signer, digester, logger) {
10
+ this.#remoteKeyStorage = remoteKeyStorage
11
+ this.#signer = signer
12
+ this.#digester = digester
13
+ this.#logger = logger.child({ class: this.constructor.name })
14
+ }
15
+
16
+ async authenticate (req, res, next) {
17
+ const signature = req.get('Signature')
18
+ if (!signature) {
19
+ // Just continue
20
+ return next()
21
+ }
22
+ const date = req.get('Date')
23
+ if (!date) {
24
+ return next(createHttpError(400, 'No date provided'))
25
+ }
26
+ try {
27
+ if (Math.abs(Date.parse(date) - Date.now()) >
28
+ HTTPSignatureAuthenticator.#maxDateDiff) {
29
+ return next(createHttpError(400, 'Time skew too large'))
30
+ }
31
+ } catch (err) {
32
+ // for date parsing errors
33
+ return next(err)
34
+ }
35
+ if (req.rawBodyText && req.rawBodyText.length > 0) {
36
+ const digest = req.get('Digest')
37
+ if (!digest) {
38
+ return next(createHttpError(400, 'No digest provided'))
39
+ }
40
+ const calculated = await this.#digester.digest(req.rawBodyText)
41
+ if (!this.#digester.equals(digest, calculated)) {
42
+ this.#logger.debug(`calculated: ${calculated} digest: ${digest}`)
43
+ return next(createHttpError(400, 'Digest mismatch'))
44
+ }
45
+ }
46
+ const { method, headers } = req
47
+ const originalUrl = req.originalUrl
48
+ this.#logger.debug({ originalUrl }, 'original URL')
49
+ try {
50
+ const keyId = this.#signer.keyId(signature)
51
+ const ok = await this.#remoteKeyStorage.getPublicKey(keyId)
52
+ let owner = ok.owner
53
+ let publicKeyPem = ok.publicKeyPem
54
+ let result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
55
+ this.#logger.debug(`First validation result: ${result}`)
56
+ if (!result) {
57
+ // May be key rotation. Try again with uncached key
58
+ const ok2 = await this.#remoteKeyStorage.getPublicKey(keyId, false)
59
+ if (ok2.publicKeyPem === ok.publicKeyPem) {
60
+ this.#logger.debug('same keys')
61
+ } else {
62
+ this.#logger.debug('different keys')
63
+ owner = ok2.owner
64
+ publicKeyPem = ok2.publicKeyPem
65
+ result = await this.#signer.validate(publicKeyPem, signature, method, originalUrl, headers)
66
+ this.#logger.debug(`Validation result: ${result}`)
67
+ }
68
+ }
69
+ if (result) {
70
+ this.#logger.debug(`Signature valid for ${keyId}`)
71
+ req.auth = req.auth || {}
72
+ req.auth.subject = owner
73
+ return next()
74
+ } else {
75
+ return next(createHttpError(401, 'Unauthorized'))
76
+ }
77
+ } catch (err) {
78
+ return next(err)
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,113 @@
1
+ import { promisify } from 'util'
2
+ import crypto from 'node:crypto'
3
+ import HumanHasher from 'humanhash'
4
+ import assert from 'node:assert'
5
+
6
+ const generateKeyPair = promisify(crypto.generateKeyPair)
7
+
8
+ export class KeyStorage {
9
+ #connection = null
10
+ #logger = null
11
+ #hasher = null
12
+ constructor (connection, logger) {
13
+ assert.ok(connection, 'connection is required')
14
+ assert.ok(logger, 'logger is required')
15
+ this.#connection = connection
16
+ this.#logger = logger.child({ class: this.constructor.name })
17
+ this.#hasher = new HumanHasher()
18
+ }
19
+
20
+ async initialize () {
21
+ await this.#connection.query(`
22
+ CREATE TABLE IF NOT EXISTS new_keys (
23
+ username varchar(512) PRIMARY KEY,
24
+ public_key TEXT,
25
+ private_key TEXT
26
+ )
27
+ `)
28
+ try {
29
+ await this.#connection.query(`
30
+ INSERT OR IGNORE INTO new_keys (username, public_key, private_key)
31
+ SELECT bot_id, public_key, private_key
32
+ FROM keys
33
+ `)
34
+ } catch (error) {
35
+ this.#logger.debug(
36
+ { error, method: 'KeyStorage.initialize' },
37
+ 'failed to copy keys to new_keys table')
38
+ }
39
+ }
40
+
41
+ async getPublicKey (username) {
42
+ this.#logger.debug(
43
+ { username, method: 'KeyStorage.getPublicKey' },
44
+ 'getting public key for bot')
45
+ const [publicKey] = await this.#getKeys(username)
46
+ return publicKey
47
+ }
48
+
49
+ async getPrivateKey (username) {
50
+ this.#logger.debug(
51
+ { username, method: 'KeyStorage.getPrivateKey' },
52
+ 'getting private key for bot')
53
+ const [, privateKey] = await this.#getKeys(username)
54
+ return privateKey
55
+ }
56
+
57
+ async #getKeys (username) {
58
+ let privateKey
59
+ let publicKey
60
+ const [result] = await this.#connection.query(`
61
+ SELECT public_key, private_key FROM new_keys WHERE username = ?
62
+ `, { replacements: [username] })
63
+ if (result.length > 0) {
64
+ this.#logger.debug(
65
+ { username, method: 'KeyStorage.#getKeys' },
66
+ 'found key for bot in database')
67
+ publicKey = result[0].public_key
68
+ privateKey = result[0].private_key
69
+ } else {
70
+ this.#logger.debug(
71
+ { username, method: 'KeyStorage.#getKeys' },
72
+ 'no key for bot, generating new key'
73
+ );
74
+ [publicKey, privateKey] = await this.#newKeyPair(username)
75
+ this.#logger.debug(
76
+ { username, method: 'KeyStorage.#getKeys' },
77
+ 'saving new keypair to database'
78
+ )
79
+ await this.#saveKeyPair(username, publicKey, privateKey)
80
+ }
81
+ this.#logger.debug({
82
+ username,
83
+ method: 'KeyStorage.#getKeys',
84
+ publicKeyHash: this.#hasher.humanize(publicKey),
85
+ privateKeyHash: this.#hasher.humanize(privateKey)
86
+ })
87
+ return [publicKey, privateKey]
88
+ }
89
+
90
+ async #newKeyPair (username) {
91
+ const { publicKey, privateKey } = await generateKeyPair(
92
+ 'rsa',
93
+ {
94
+ modulusLength: 2048,
95
+ privateKeyEncoding: {
96
+ type: 'pkcs8',
97
+ format: 'pem'
98
+ },
99
+ publicKeyEncoding: {
100
+ type: 'spki',
101
+ format: 'pem'
102
+ }
103
+ }
104
+ )
105
+ return [publicKey, privateKey]
106
+ }
107
+
108
+ async #saveKeyPair (username, publicKey, privateKey) {
109
+ await this.#connection.query(`
110
+ INSERT INTO new_keys (username, public_key, private_key) VALUES (?, ?, ?)
111
+ `, { replacements: [username, publicKey, privateKey] })
112
+ }
113
+ }
@@ -0,0 +1,140 @@
1
+ import assert from 'node:assert'
2
+ import fetch from 'node-fetch'
3
+
4
+ const AS2 = 'https://www.w3.org/ns/activitystreams#'
5
+
6
+ export class Transformer {
7
+ #tagNamespace = null
8
+ #client = null
9
+ constructor (tagNamespace, client) {
10
+ this.#tagNamespace = tagNamespace
11
+ this.#client = client
12
+ assert.ok(this.#tagNamespace)
13
+ assert.ok(this.#client)
14
+ }
15
+
16
+ async transform (text) {
17
+ let html = text
18
+ let tag = [];
19
+ ({ html, tag } = this.#replaceUrls(html, tag));
20
+ ({ html, tag } = this.#replaceHashtags(html, tag));
21
+ ({ html, tag } = await this.#replaceMentions(html, tag))
22
+ html = `<p>${html}</p>`
23
+ return { html, tag }
24
+ }
25
+
26
+ #replaceUrls (html, tag) {
27
+ const url = /https?:\/\/\S+/g
28
+ const segments = this.#segment(html)
29
+ for (const i in segments) {
30
+ const segment = segments[i]
31
+ if (this.#isLink(segment)) continue
32
+ segments[i] = segment.replace(url, (match) => {
33
+ return `<a href="${match}">${match}</a>`
34
+ })
35
+ }
36
+ return { html: segments.join(''), tag }
37
+ }
38
+
39
+ #replaceHashtags (html, tag) {
40
+ const hashtag = /#(\w+)/g
41
+ const segments = this.#segment(html)
42
+ for (const i in segments) {
43
+ const segment = segments[i]
44
+ if (this.#isLink(segment)) continue
45
+ segments[i] = segment.replace(hashtag, (match, name) => {
46
+ const href = this.#tagNamespace + name
47
+ tag.push({ type: AS2 + 'Hashtag', name: match, href })
48
+ return `<a href="${href}">${match}</a>`
49
+ })
50
+ }
51
+ return { html: segments.join(''), tag }
52
+ }
53
+
54
+ async #replaceMentions (html, tag) {
55
+ const self = this
56
+ const webfinger = /@[a-zA-Z0-9_]+([a-zA-Z0-9_.-]+[a-zA-Z0-9_]+)?@[a-zA-Z0-9_.-]+/g
57
+ const segments = this.#segment(html)
58
+ for (const i in segments) {
59
+ const segment = segments[i]
60
+ if (this.#isLink(segment)) continue
61
+ segments[i] = await this.#replaceAsync(segments[i], webfinger, async (match) => {
62
+ const href = await self.#homePage(match.slice(1))
63
+ if (!href) return match
64
+ tag.push({ type: 'Mention', name: match, href })
65
+ return `<a href="${href}">${match}</a>`
66
+ })
67
+ }
68
+ return { html: segments.join(''), tag }
69
+ }
70
+
71
+ async #homePage (webfinger) {
72
+ if (!this.#client) return null
73
+ const [username, domain] = webfinger.split('@')
74
+ const url = `https://${domain}/.well-known/webfinger?` +
75
+ `resource=acct:${username}@${domain}`
76
+ let json = null
77
+ try {
78
+ const response = await fetch(url,
79
+ { headers: { Accept: 'application/jrd+json' } })
80
+ if (response.status !== 200) return null
81
+ json = await response.json()
82
+ } catch (error) {
83
+ return null
84
+ }
85
+ if (!json.links) return null
86
+ const link = json.links.find(
87
+ link => link.rel === 'self' &&
88
+ (link.type === 'application/activity+json' ||
89
+ link.type === 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'))
90
+ if (!link) return null
91
+ const actorId = link.href
92
+ if (!actorId) return null
93
+ let actor = null
94
+ try {
95
+ actor = await this.#client.get(actorId)
96
+ } catch (error) {
97
+ console.error(error)
98
+ return null
99
+ }
100
+ if (!actor) return null
101
+ if (!actor.url) {
102
+ return actorId
103
+ }
104
+ for (const url of actor.url) {
105
+ if (url.type === AS2 + 'Link' &&
106
+ url.mediaType === 'text/html' &&
107
+ url.href) {
108
+ return url.href
109
+ }
110
+ }
111
+ // Fallback to first URL
112
+ if (actor.url.length === 1 && !actor.url.first.type) {
113
+ return actor.url.first.id
114
+ }
115
+ // Fallback even further to actor ID
116
+ return actorId
117
+ }
118
+
119
+ #isLink (segment) {
120
+ return (segment.startsWith('<a>') || segment.startsWith('<a ')) && segment.endsWith('</a>')
121
+ }
122
+
123
+ #segment (html) {
124
+ return html.split(/(<[^>]+>[^<]+<\/[^>]+>)/)
125
+ }
126
+
127
+ async #replaceAsync (str, regex, asyncFn) {
128
+ const promises = []
129
+ str.replace(regex, (match, ...args) => {
130
+ // Add a promise for each match
131
+ promises.push(asyncFn(match, ...args))
132
+ })
133
+
134
+ // Wait for all async replacements to resolve
135
+ const replacements = await Promise.all(promises)
136
+
137
+ // Replace the matches with their respective replacements
138
+ return str.replace(regex, () => replacements.shift())
139
+ }
140
+ }