@evanp/activitypub-bot 0.52.1 → 0.53.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/.dockerignore ADDED
@@ -0,0 +1,8 @@
1
+ .git
2
+ .cache
3
+ .claude
4
+ .vscode
5
+ node_modules
6
+ **/.DS_Store
7
+ .env
8
+ .env.*
package/CHANGELOG.md CHANGED
@@ -9,6 +9,31 @@ and this project adheres to
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.53.0] - 2026-10-08
13
+
14
+ ### Changed
15
+
16
+ - Upgrade activitypub-webfinger to 0.3.0 and add BotContext tests for forward
17
+ and reverse discovery with non-ASCII usernames and domains (#282).
18
+
19
+ ### Fixed
20
+
21
+ - Linkify mentions with non-ASCII usernames and domains or ASCII punctuation
22
+ in usernames, preserving HTML escaping and URL fragments; add regression
23
+ tests for Unicode handles and username punctuation (#282).
24
+ - Normalize server origins to support Unicode domains; add regression tests
25
+ for discovery, actor and collection endpoints, and OKBot replies (#282).
26
+ - Support Unicode local usernames by encoding URL paths and acct: URIs and
27
+ decoding usernames during URL parsing and WebFinger lookup; return 400 for
28
+ malformed WebFinger username escapes and add regression tests (#282).
29
+
30
+ ## [0.52.2] - 2026-10-05
31
+
32
+ ### Fixed
33
+
34
+ - Build Docker images from the local package instead of waiting for the new
35
+ release to become available on npm.
36
+
12
37
  ## [0.52.1] - 2026-10-05
13
38
 
14
39
  ### Changed
@@ -1688,7 +1713,9 @@ Hot patch on the `stable` branch.
1688
1713
  - Docker multi-platform build workflow.
1689
1714
  - Dependabot configuration.
1690
1715
 
1691
- [Unreleased]: https://github.com/evanp/activitypub-bot/compare/v0.52.1...HEAD
1716
+ [Unreleased]: https://github.com/evanp/activitypub-bot/compare/v0.53.0...HEAD
1717
+ [0.53.0]: https://github.com/evanp/activitypub-bot/compare/v0.52.2...v0.53.0
1718
+ [0.52.2]: https://github.com/evanp/activitypub-bot/compare/v0.52.1...v0.52.2
1692
1719
  [0.52.1]: https://github.com/evanp/activitypub-bot/compare/v0.52.0...v0.52.1
1693
1720
  [0.52.0]: https://github.com/evanp/activitypub-bot/compare/v0.51.4...v0.52.0
1694
1721
  [0.51.4]: https://github.com/evanp/activitypub-bot/compare/v0.51.3...v0.51.4
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.1",
3
+ "version": "0.53.0",
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",