@evanp/activitypub-bot 0.52.2 → 0.53.1

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/CHANGELOG.md CHANGED
@@ -9,6 +9,31 @@ and this project adheres to
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.53.1] - 2026-10-09
13
+
14
+ ### Security
15
+
16
+ - Update proxy-addr from 2.0.7 to 2.0.8 to address a security vulnerability
17
+ (Dependabot #284).
18
+
19
+ ## [0.53.0] - 2026-10-08
20
+
21
+ ### Changed
22
+
23
+ - Upgrade activitypub-webfinger to 0.3.0 and add BotContext tests for forward
24
+ and reverse discovery with non-ASCII usernames and domains (#282).
25
+
26
+ ### Fixed
27
+
28
+ - Linkify mentions with non-ASCII usernames and domains or ASCII punctuation
29
+ in usernames, preserving HTML escaping and URL fragments; add regression
30
+ tests for Unicode handles and username punctuation (#282).
31
+ - Normalize server origins to support Unicode domains; add regression tests
32
+ for discovery, actor and collection endpoints, and OKBot replies (#282).
33
+ - Support Unicode local usernames by encoding URL paths and acct: URIs and
34
+ decoding usernames during URL parsing and WebFinger lookup; return 400 for
35
+ malformed WebFinger username escapes and add regression tests (#282).
36
+
12
37
  ## [0.52.2] - 2026-10-05
13
38
 
14
39
  ### Fixed
@@ -1695,7 +1720,9 @@ Hot patch on the `stable` branch.
1695
1720
  - Docker multi-platform build workflow.
1696
1721
  - Dependabot configuration.
1697
1722
 
1698
- [Unreleased]: https://github.com/evanp/activitypub-bot/compare/v0.52.2...HEAD
1723
+ [Unreleased]: https://github.com/evanp/activitypub-bot/compare/v0.53.1...HEAD
1724
+ [0.53.1]: https://github.com/evanp/activitypub-bot/compare/v0.53.0...v0.53.1
1725
+ [0.53.0]: https://github.com/evanp/activitypub-bot/compare/v0.52.2...v0.53.0
1699
1726
  [0.52.2]: https://github.com/evanp/activitypub-bot/compare/v0.52.1...v0.52.2
1700
1727
  [0.52.1]: https://github.com/evanp/activitypub-bot/compare/v0.52.0...v0.52.1
1701
1728
  [0.52.0]: https://github.com/evanp/activitypub-bot/compare/v0.51.4...v0.52.0
package/lib/app.js CHANGED
@@ -72,6 +72,8 @@ function createWorkers (logger, count, WorkerClass, ...args) {
72
72
  }
73
73
 
74
74
  export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME, allowPrivateNetworkRequests = false, redisUrl, trustProxy, domainBlockFileName }) {
75
+ // normalize the origin, including Unicode -> punycode
76
+ origin = (new URL(origin)).origin
75
77
  const logger = Logger({
76
78
  level: logLevel
77
79
  })
@@ -30,8 +30,8 @@ export class Transformer {
30
30
  let html = this.#escape(text)
31
31
  let tag = [];
32
32
  ({ html, tag } = this.#replaceUrls(html, tag));
33
- ({ html, tag } = this.#replaceHashtags(html, tag));
34
- ({ html, tag } = await this.#replaceMentions(html, tag))
33
+ ({ html, tag } = await this.#replaceMentions(html, tag));
34
+ ({ html, tag } = this.#replaceHashtags(html, tag))
35
35
  html = `<p>${html}</p>`
36
36
  return { html, tag }
37
37
  }
@@ -67,36 +67,19 @@ export class Transformer {
67
67
  }
68
68
 
69
69
  async #replaceMentions (html, tag) {
70
- const self = this
71
- const webfinger = /@[a-zA-Z0-9_]+([a-zA-Z0-9_.-]+[a-zA-Z0-9_]+)?@[a-zA-Z0-9_.-]+/g
72
70
  const segments = this.#segment(html)
73
71
  for (const i in segments) {
74
- const segment = segments[i]
75
- if (this.#isLink(segment)) continue
76
- segments[i] = await this.#replaceAsync(segments[i], webfinger, async (match) => {
77
- const href = await self.#homePage(match.slice(1))
78
- if (!href) return match
79
- let url
80
- try {
81
- url = new URL(href)
82
- } catch (err) {
83
- return match
84
- }
85
- if (url.protocol !== 'https:') {
86
- return match
87
- }
88
- tag.push({ type: 'Mention', name: match, href: url.href })
89
- const escaped = this.#escape(url.href)
90
- const escapedMatch = this.#escape(match)
91
- return `<a href="${escaped}">${escapedMatch}</a>`
92
- })
72
+ if (this.#isLink(segments[i])) continue
73
+ segments[i] = await this.#replaceMention(this.#unescape(segments[i]), tag)
93
74
  }
94
75
  return { html: segments.join(''), tag }
95
76
  }
96
77
 
97
78
  async #homePage (webfinger) {
98
79
  if (!this.#client) return null
99
- const [username, domain] = webfinger.split('@')
80
+ const delimiter = webfinger.lastIndexOf('@')
81
+ const username = webfinger.slice(0, delimiter)
82
+ const domain = webfinger.slice(delimiter + 1)
100
83
  if (domain.toLowerCase() === this.#formatter.hostname) {
101
84
  return this.#formatter.format({ username, type: 'profile' })
102
85
  }
@@ -136,18 +119,43 @@ export class Transformer {
136
119
  return html.split(/(<[^>]+>[^<]+<\/[^>]+>)/)
137
120
  }
138
121
 
139
- async #replaceAsync (str, regex, asyncFn) {
122
+ async #transformMention (match, tag) {
123
+ const escapedMatch = this.#escape(match)
124
+ const href = await this.#homePage(match.slice(1))
125
+ if (!href) return escapedMatch
126
+ let url
127
+ try {
128
+ url = new URL(href)
129
+ } catch (err) {
130
+ return escapedMatch
131
+ }
132
+ if (url.protocol !== 'https:') {
133
+ return escapedMatch
134
+ }
135
+ tag.push({ type: 'Mention', name: match, href: url.href })
136
+ const escaped = this.#escape(url.href)
137
+ return `<a href="${escaped}">${escapedMatch}</a>`
138
+ }
139
+
140
+ async #replaceMention (str, tag) {
141
+ const webfinger = /(@[\x21-\x7E\p{L}\p{Nd}\p{M}]+@[\p{L}\p{Nd}](?:[\p{L}\p{Nd}\p{M}-]*[\p{L}\p{Nd}\p{M}])?(?:\.[\p{L}\p{Nd}](?:[\p{L}\p{Nd}\p{M}-]*[\p{L}\p{Nd}\p{M}])?)*)/ug
142
+
143
+ const parts = str.split(webfinger)
144
+
140
145
  const promises = []
141
- str.replace(regex, (match, ...args) => {
142
- // Add a promise for each match
143
- promises.push(asyncFn(match, ...args))
144
- })
146
+
147
+ for (const i in parts) {
148
+ promises[i] = (i % 2 === 0)
149
+ ? new Promise((resolve, reject) => {
150
+ resolve(this.#escape(parts[i]))
151
+ })
152
+ : this.#transformMention(parts[i], tag)
153
+ }
145
154
 
146
155
  // Wait for all async replacements to resolve
147
156
  const replacements = await Promise.all(promises)
148
157
 
149
- // Replace the matches with their respective replacements
150
- return str.replace(regex, () => replacements.shift())
158
+ return replacements.join('')
151
159
  }
152
160
 
153
161
  #escape (text) {
@@ -158,4 +166,13 @@ export class Transformer {
158
166
  .replace(/"/g, '&quot;')
159
167
  .replace(/'/g, '&apos;')
160
168
  }
169
+
170
+ #unescape (text) {
171
+ return text
172
+ .replace(/&lt;/g, '<')
173
+ .replace(/&gt;/g, '>')
174
+ .replace(/&quot;/g, '"')
175
+ .replace(/&apos;/g, '\'')
176
+ .replace(/&amp;/g, '&')
177
+ }
161
178
  }
@@ -70,15 +70,24 @@ async function httpsWebfinger (resource, req, res, next) {
70
70
  }
71
71
 
72
72
  async function acctWebfinger (resource, req, res, next) {
73
- const [username, domain] = resource.substring(5).split('@')
74
- if (!username || !domain) {
73
+ const address = resource.substring(5)
74
+ const delimiter = address.lastIndexOf('@')
75
+ if (delimiter <= 0 || delimiter === address.length - 1) {
75
76
  return next(new ProblemDetailsError(400, `Invalid resource parameter ${resource}`))
76
77
  }
78
+ const username = address.slice(0, delimiter)
79
+ const domain = address.slice(delimiter + 1)
77
80
  const { host } = new URL(req.app.locals.origin)
78
81
  if (domain !== host) {
79
82
  return next(new ProblemDetailsError(400, `Invalid domain ${domain} in resource parameter`))
80
83
  }
81
- return await botWebfinger(username, req, res, next)
84
+ let decoded
85
+ try {
86
+ decoded = decodeURIComponent(username)
87
+ } catch (err) {
88
+ return next(new ProblemDetailsError(400, `Invalid percent-encoded username ${username} in resource parameter`))
89
+ }
90
+ return await botWebfinger(decoded, req, res, next)
82
91
  }
83
92
 
84
93
  router.get('/.well-known/webfinger', async (req, res, next) => {
@@ -16,9 +16,9 @@ export class UrlFormatter {
16
16
  if (server) {
17
17
  return this.format({ username: this.#hostname, type, nanoid, collection, page, server: false })
18
18
  } else if (username && type && type === 'profile') {
19
- return `${this.#origin}/profile/${username}`
19
+ return `${this.#origin}/profile/${encodeURIComponent(username)}`
20
20
  } else if (username) {
21
- base = `${this.#origin}/user/${username}`
21
+ base = `${this.#origin}/user/${encodeURIComponent(username)}`
22
22
  } else {
23
23
  throw new Error('Cannot format URL without username or server')
24
24
  }
@@ -67,11 +67,11 @@ export class UrlFormatter {
67
67
  }
68
68
  let pathParts = parsed.pathname.slice(1).split('/')
69
69
  if (pathParts.length > 0 && pathParts[0] === 'user') {
70
- parts.username = pathParts[1]
70
+ parts.username = decodeURIComponent(pathParts[1])
71
71
  pathParts = pathParts.slice(2)
72
72
  parts.server = (parts.username === this.#hostname)
73
73
  } else if (pathParts.length > 0 && pathParts[0] === 'profile') {
74
- parts.username = pathParts[1]
74
+ parts.username = decodeURIComponent(pathParts[1])
75
75
  parts.type = 'profile'
76
76
  return parts
77
77
  }
@@ -108,7 +108,7 @@ export class UrlFormatter {
108
108
 
109
109
  acct (username = null) {
110
110
  return (username)
111
- ? `acct:${username}@${this.#hostname}`
111
+ ? `acct:${encodeURIComponent(username)}@${this.#hostname}`
112
112
  : `acct:${this.#hostname}@${this.#hostname}`
113
113
  }
114
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@isaacs/ttlcache": "^2.1.4",
34
34
  "@socialwebfoundation/ap-components": "^1.4.2",
35
- "activitypub-webfinger": "^0.2.1",
35
+ "activitypub-webfinger": "^0.3.0",
36
36
  "activitystrea.ms": "^4.0.1",
37
37
  "csv-parse": "^7.0.0",
38
38
  "express": "^5.2.1",
@@ -1 +0,0 @@
1
- [{"/Users/evanp/Documents/Code/activitypub-bot/tests/activitydeliverer.test.js":"1"},{"size":1743,"mtime":1770107423341,"results":"2","hashOfConfig":"3"},{"filePath":"4","messages":"5","suppressedMessages":"6","errorCount":18,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},"eeiv40","/Users/evanp/Documents/Code/activitypub-bot/tests/activitydeliverer.test.js",["7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24"],[],{"ruleId":"25","severity":2,"message":"26","line":20,"column":14,"nodeType":"27","messageId":"28","endLine":20,"endColumn":20},{"ruleId":"25","severity":2,"message":"29","line":22,"column":5,"nodeType":"27","messageId":"28","endLine":22,"endColumn":15},{"ruleId":"25","severity":2,"message":"29","line":23,"column":11,"nodeType":"27","messageId":"28","endLine":23,"endColumn":21},{"ruleId":"25","severity":2,"message":"30","line":24,"column":11,"nodeType":"27","messageId":"28","endLine":24,"endColumn":24},{"ruleId":"25","severity":2,"message":"29","line":24,"column":25,"nodeType":"27","messageId":"28","endLine":24,"endColumn":35},{"ruleId":"25","severity":2,"message":"31","line":25,"column":24,"nodeType":"27","messageId":"28","endLine":25,"endColumn":36},{"ruleId":"25","severity":2,"message":"29","line":25,"column":37,"nodeType":"27","messageId":"28","endLine":25,"endColumn":47},{"ruleId":"25","severity":2,"message":"32","line":26,"column":5,"nodeType":"27","messageId":"28","endLine":26,"endColumn":18},{"ruleId":"25","severity":2,"message":"29","line":26,"column":39,"nodeType":"27","messageId":"28","endLine":26,"endColumn":49},{"ruleId":"25","severity":2,"message":"33","line":27,"column":5,"nodeType":"27","messageId":"28","endLine":27,"endColumn":15},{"ruleId":"25","severity":2,"message":"29","line":27,"column":33,"nodeType":"27","messageId":"28","endLine":27,"endColumn":43},{"ruleId":"25","severity":2,"message":"34","line":28,"column":24,"nodeType":"27","messageId":"28","endLine":28,"endColumn":37},{"ruleId":"25","severity":2,"message":"35","line":29,"column":26,"nodeType":"27","messageId":"28","endLine":29,"endColumn":34},{"ruleId":"25","severity":2,"message":"33","line":30,"column":36,"nodeType":"27","messageId":"28","endLine":30,"endColumn":46},{"ruleId":"25","severity":2,"message":"32","line":33,"column":7,"nodeType":"27","messageId":"28","endLine":33,"endColumn":20},{"ruleId":"25","severity":2,"message":"36","line":34,"column":7,"nodeType":"27","messageId":"28","endLine":34,"endColumn":18},{"ruleId":"25","severity":2,"message":"37","line":36,"column":7,"nodeType":"27","messageId":"28","endLine":36,"endColumn":12},{"ruleId":"25","severity":2,"message":"38","line":37,"column":7,"nodeType":"27","messageId":"28","endLine":37,"endColumn":12},"no-undef","'Logger' is not defined.","Identifier","undef","'connection' is not defined.","'runMigrations' is not defined.","'ActorStorage' is not defined.","'objectStorage' is not defined.","'keyStorage' is not defined.","'HTTPSignature' is not defined.","'Digester' is not defined.","'distributor' is not defined.","'cache' is not defined.","'authz' is not defined."]
@@ -1,25 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(kubectl get *)",
5
- "Bash(kubectl top *)",
6
- "Read(//private/tmp/heap-snapshots/**)",
7
- "Bash(node --max-old-space-size=8192 analyze.mjs snap1-30min-mppzz.heapsnapshot)",
8
- "Bash(tee analyze1.out)",
9
- "Bash(node --max-old-space-size=8192 fr-owners.mjs snap1-30min-mppzz.heapsnapshot)",
10
- "Bash(node --max-old-space-size=8192 fr-trace.mjs snap1-30min-mppzz.heapsnapshot 123343)",
11
- "Bash(node --max-old-space-size=10240 diff.mjs snap1-30min-mppzz.heapsnapshot snap2-92min-mppzz.heapsnapshot)",
12
- "Bash(tee diff.out)",
13
- "Bash(mkdir -p /Users/evanp/Documents/Code/aode-relay-test/relay /Users/evanp/Documents/Code/aode-relay-test/bots && ls /Users/evanp/Documents/Code/aode-relay-test/)",
14
- "Bash(docker builder *)",
15
- "Bash(docker image *)",
16
- "Bash(docker container *)",
17
- "Bash(docker run *)"
18
- ],
19
- "additionalDirectories": [
20
- "/tmp/heap-snapshots",
21
- "/private/tmp/heap-snapshots",
22
- "/Users/evanp/Documents/Code/aode-relay-test"
23
- ]
24
- }
25
- }
@@ -1,10 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(docker compose *)",
5
- "Bash(grep -E \"test$|elk|gotosocial|tags\" /etc/hosts 2>&1 | head -20)",
6
- "Read(//private/etc/**)",
7
- "Bash(kubectl exec *)"
8
- ]
9
- }
10
- }
@@ -1,17 +0,0 @@
1
- {
2
- "editor.formatOnSave": false,
3
- "editor.formatOnSaveMode": "modificationsIfAvailable",
4
- "editor.codeActionsOnSave": {
5
- "source.fixAll.eslint": "explicit"
6
- },
7
- "javascript.format.enable": false,
8
- "standard.enable": false,
9
- "standard.autoFixOnSave": false,
10
- "eslint.format.enable": true,
11
- "eslint.validate": [
12
- "javascript"
13
- ],
14
- "[javascript]": {
15
- "editor.defaultFormatter": "dbaeumer.vscode-eslint"
16
- }
17
- }
@@ -1,13 +0,0 @@
1
- {
2
- "version": "2.0.0",
3
- "tasks": [
4
- {
5
- "type": "npm",
6
- "script": "test",
7
- "group": "test",
8
- "problemMatcher": [],
9
- "label": "npm: test",
10
- "detail": "NODE_ENV=test node --test"
11
- }
12
- ]
13
- }