matrix-nodejs-homeserver 0.3.1 → 0.3.2

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
@@ -1,11 +1,16 @@
1
1
  # matrix-nodejs-homeserver
2
2
 
3
+ [![Load test: SQLite](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fsqlite.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
4
+ [![Load test: Postgres](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-nocluster.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
5
+ [![Load test: Postgres cluster, 1 node](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-1.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
6
+ [![Load test: Postgres cluster, 5 nodes](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fk18k%2Fmatrix-nodejs-homeserver%2Floadtest-results%2Fbadges%2Fpg-cluster-5.json)](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
7
+
3
8
  A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
4
9
  tied to any HTTP framework, database, auth system or deployment shape. You can use
5
10
  it for a demo on SQLite, embed it in a monolith next to your existing users, or run
6
11
  it as a horizontally scaled chat backend that replaces Slack or Teams.
7
12
 
8
- - Node.js **26+**. TypeScript with only erasable syntax (`erasableSyntaxOnly`), and no runtime dependencies ([zod](https://zod.dev), used for request validation, is bundled).
13
+ - Node.js **22+** (tested on 22, 24 and 26; development uses 26). TypeScript with only erasable syntax (`erasableSyntaxOnly`), and no runtime dependencies ([zod](https://zod.dev), used for request validation, is bundled).
9
14
  - Client-Server API, Server-Server API (federation), end-to-end encryption, sliding sync, VoIP/MatrixRTC and push
10
15
  - Runs on `node:http`, Express, Fastify, Hono or any fetch runtime
11
16
  - Stores data in SQLite, PostgreSQL, MySQL, memory or your own adapter
@@ -75,12 +80,12 @@ By default a homeserver assumes it is the only process using its database. To ru
75
80
  several instances behind a load balancer, give them a shared **cluster adapter**:
76
81
 
77
82
  ```ts
78
- import { postgresCluster, redisCluster } from "matrix-nodejs-homeserver";
83
+ import { postgresCluster } from "matrix-nodejs-homeserver";
79
84
 
80
85
  createHomeserver({
81
86
  homeserver: "example.com",
82
87
  databaseAdapter: postgresAdapter(pool),
83
- cluster: postgresCluster(pool), // or: redisCluster({ client: new Redis(), subscriber: new Redis() })
88
+ cluster: postgresCluster(pool),
84
89
  });
85
90
  ```
86
91
 
@@ -95,8 +100,7 @@ The cluster adapter provides:
95
100
  - **Shared ephemeral state:** UIA sessions, SSO logins and transaction deduplication.
96
101
 
97
102
  [examples/multi-node.ts](examples/multi-node.ts) is meant to run unchanged on several
98
- machines behind a load balancer, all sharing one Postgres database (and optionally Redis
99
- for coordination). It also shows a media adapter backed by that database, since local
103
+ machines behind a load balancer, all sharing one Postgres database, which also coordinates them. It also shows a media adapter backed by that database, since local
100
104
  disks differ between machines.
101
105
 
102
106
  The database adapter has to support concurrent access from several processes, so
@@ -130,6 +134,9 @@ createHomeserver({
130
134
  clientId, clientSecret, mapUser: (claims) => ({ localpart: claims.email.split("@")[0] }) }] },
131
135
  publicBaseUrl: "https://matrix.example.com",
132
136
  ```
137
+ After signing in, users confirm the client the login token goes to, on a page
138
+ that names it. Clients you trust skip that page:
139
+ `sso: { providers, clientAllowlist: ["https://app.element.io/"] }`.
133
140
  - **Delegated OAuth 2.0** (the Matrix OAuth API, e.g. with
134
141
  matrix-authentication-service, Keycloak or Authentik). The provider owns the
135
142
  accounts, and the homeserver validates its access tokens by introspection:
@@ -328,6 +335,7 @@ paths (server admins only), so existing tools keep working.
328
335
  | `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO. |
329
336
  | `oauth`, `sso` | Delegated OAuth or upstream OIDC SSO. |
330
337
  | `federation` | `{ enabled, verifyTls, allowedServers, blockedServers, fasterJoins }`. Off unless `enabled: true`. |
338
+ | `allowPrivateAddresses` | Lets requests to addresses that users or remote servers choose (federation, push gateways, identity servers, URL previews, remote media) reach private addresses: `true`, or ranges such as `["127.0.0.1/32"]`. Blocked by default. |
331
339
  | `rtc` | `{ turn, transports }` |
332
340
  | `delayedEvents` | `{ maxDelayMs, maxPerUser }` |
333
341
  | `rateLimits` | `false`, or `{ limiter, rules }`. In-memory token buckets by default. Plug in a shared limiter when running a cluster. |
@@ -451,6 +459,7 @@ Live status, updated after every Complement run (full or targeted) with
451
459
 
452
460
  | Run | Top-level | Subtests | Note |
453
461
  |---|---|---|---|
462
+ | 9 | 214 / 214 | 851 / 851 | zod, procedures, M/UNSTABLE, SSO confirmation, outbound address policy, admin guard (ec5aa79) |
454
463
  | 8 | 214 / 214 | 851 / 851 | Complement runs in a Go container (no submodule, no local Go); account locking/suspension, policy servers, 3PIDs, legacy sync endpoints |
455
464
  | 7 | 214 / 214 | 851 / 851 | All Complement tests pass (previous full run: 211/214). Fallback keys stored apart from one-time keys; user directory public-room rules; MSC4311 create event required from v12 |
456
465
  | 6 | 189 / 214 | 803 / 851 | Partial-state joins (MSC3902): all subtests pass |
@@ -471,6 +480,53 @@ Live status, updated after every Complement run (full or targeted) with
471
480
  CI runs the whole suite with the same script. `complement/passing.txt` lists the tests
472
481
  (and subtests) that are known to pass, and CI fails if any of them regress.
473
482
 
483
+ ### Load testing
484
+
485
+ Tuning and investigation runs are local; a [workflow](.github/workflows/loadtest.yml) runs the same scenarios on main for the badges. [loadtest/](loadtest/) holds a k6 scenario and a
486
+ homeserver that reports, every 10 s, requests per second, memory, event-loop delay and
487
+ (with Postgres) how many queries wait for a pool connection and how long locks are held.
488
+
489
+ The scenario: `ONLINE_USERS` each hold a long-polling `/sync` open in one of `ROOMS` rooms,
490
+ while `MESSAGES_PER_SECOND` messages are sent in total. Every user registers and joins
491
+ through the client API. It measures each request type and the time from sending a message to
492
+ its arrival in other users' `/sync` (`message_delivery`).
493
+
494
+ ```sh
495
+ # Postgres with N homeserver nodes behind nginx (round-robin, no sticky sessions)
496
+ ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 RAMP=30s DURATION=30s \
497
+ docker compose -p matrix-loadtest -f loadtest/docker-compose.yml up --build \
498
+ --scale homeserver=5 --abort-on-container-exit --exit-code-from k6 k6
499
+ # CLUSTER=0: one process on Postgres without a cluster adapter
500
+
501
+ # SQLite, one process, no Docker (needs k6 installed)
502
+ DATA_DIR=/tmp/lt node loadtest/server.ts &
503
+ BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 k6 run loadtest/k6/matrix.js
504
+ ```
505
+
506
+ <!-- loadtest-results:start -->
507
+ The badges at the top come from the [Load test workflow](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml) on main: the same
508
+ scenario on one GitHub runner (4 CPUs), with k6 on the same machine. Each run's job summary has
509
+ the full table.
510
+
511
+ Latest results: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
512
+ 30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
513
+
514
+ | Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
515
+ |---|---|---|---|---|---|---|---|
516
+ | SQLite, 1 process (before #28–#35) | ~800 | 46 ms / 0.69 s | 0.50 s / 2.4 s | 61 ms / 0.15 s | 0.58 s / 0.78 s | 5,816 | 0% |
517
+ | Postgres, no cluster | 404 | 49 ms / 71 ms | 2.6 s / 3.6 s | 18 ms / 27 ms | 4.6 s / 6.5 s | 56,297 | 0% |
518
+ | Postgres, cluster, 1 node | 314 | 81 ms / 124 ms | 2.8 s / 4.4 s | 50 ms / 70 ms | 5.2 s / 7.5 s | 52,071 | 0% |
519
+ | Postgres, cluster, 5 nodes | 840 | 118 ms / 197 ms | 0.90 s / 1.4 s | 86 ms / 151 ms | 1.6 s / 2.3 s | 58,298 | 0% |
520
+
521
+ What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
522
+ per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
523
+ the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
524
+ server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
525
+ (#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
526
+ behind the syncs a message wakes. A Postgres node now builds ~330 syncs/s here; delivery time is
527
+ syncs waiting for their turn, and more nodes shorten it.
528
+ <!-- loadtest-results:end -->
529
+
474
530
  ## License
475
531
 
476
532
  [PolyForm Noncommercial 1.0.0](LICENSE). Free to use, modify and share for any noncommercial
@@ -19,37 +19,12 @@ export interface PgClusterPoolLike {
19
19
  connect(): Promise<PgClientLike>;
20
20
  }
21
21
  /**
22
- * Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, advisory locks,
22
+ * Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, leased locks,
23
23
  * LISTEN/NOTIFY pub/sub and an expiring key-value table. Lets several homeserver
24
24
  * instances share one database.
25
25
  */
26
26
  export declare function postgresCluster(pool: PgClusterPoolLike, options?: {
27
27
  tablePrefix?: string;
28
28
  }): ClusterAdapter;
29
- /** Minimal ioredis-compatible client. */
30
- export interface RedisLike {
31
- incr(key: string): Promise<number>;
32
- get(key: string): Promise<string | null>;
33
- set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
34
- del(...keys: string[]): Promise<number>;
35
- eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
36
- publish(channel: string, message: string): Promise<number>;
37
- scan(cursor: string | number, ...args: (string | number)[]): Promise<[string, string[]]>;
38
- mget(...keys: string[]): Promise<(string | null)[]>;
39
- }
40
- /** A Redis connection in subscriber mode (a separate connection, as Redis requires). */
41
- export interface RedisSubscriberLike {
42
- subscribe(...channels: string[]): Promise<unknown>;
43
- on(event: "message", listener: (channel: string, message: string) => void): unknown;
44
- }
45
- /**
46
- * Cluster adapter backed by Redis (ioredis API). Pass two connections: one for
47
- * commands and one dedicated to subscriptions.
48
- */
49
- export declare function redisCluster(opts: {
50
- client: RedisLike;
51
- subscriber: RedisSubscriberLike;
52
- prefix?: string;
53
- }): ClusterAdapter;
54
29
  //#endregion
55
30
  export { type ClusterAdapter, localCluster };
@@ -16,9 +16,13 @@ function localQueue() {
16
16
  };
17
17
  }
18
18
  const PG_CHANNEL = "matrix_cluster";
19
+ /** Internal pub/sub channel announcing released locks. */
20
+ const RELEASED = "__lock_released";
21
+ /** How long a lock lease lasts without renewal (it is renewed every third of that while held). */
22
+ const LOCK_TTL_MS = 3e4;
19
23
  const MAX_NOTIFY_PAYLOAD = 7e3;
20
24
  /**
21
- * Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, advisory locks,
25
+ * Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, leased locks,
22
26
  * LISTEN/NOTIFY pub/sub and an expiring key-value table. Lets several homeserver
23
27
  * instances share one database.
24
28
  */
@@ -27,6 +31,20 @@ function postgresCluster(pool, options = {}) {
27
31
  const kv = `"${options.tablePrefix ?? ""}cluster_kv"`;
28
32
  const handlers = /* @__PURE__ */ new Map();
29
33
  const queue = localQueue();
34
+ const waiting = /* @__PURE__ */ new Map();
35
+ const releasedOrAfter = (lockKey, ms) => new Promise((resolve) => {
36
+ let set = waiting.get(lockKey);
37
+ if (!set) waiting.set(lockKey, set = /* @__PURE__ */ new Set());
38
+ const wake = () => {
39
+ clearTimeout(timer);
40
+ set.delete(wake);
41
+ if (set.size === 0 && waiting.get(lockKey) === set) waiting.delete(lockKey);
42
+ resolve();
43
+ };
44
+ const timer = setTimeout(wake, ms);
45
+ set.add(wake);
46
+ });
47
+ handlers.set(RELEASED, /* @__PURE__ */ new Set([(lockKey) => [...waiting.get(lockKey) ?? []].forEach((wake) => wake())]));
30
48
  let listener = null;
31
49
  let cleanup = null;
32
50
  const ready = (async () => {
@@ -80,16 +98,38 @@ function postgresCluster(pool, options = {}) {
80
98
  lock(key, fn) {
81
99
  return queue(key, async () => {
82
100
  await ready;
83
- const client = await pool.connect();
101
+ const lockKey = `lock:${key}`;
102
+ const token = randomUUID();
103
+ const acquire = async () => {
104
+ const now = Date.now();
105
+ return ((await pool.query(`INSERT INTO ${kv} (key, value, expires_at) VALUES ($1, $2, $3)
106
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
107
+ WHERE ${kv}.expires_at IS NOT NULL AND ${kv}.expires_at <= $4 RETURNING key`, [
108
+ lockKey,
109
+ token,
110
+ now + LOCK_TTL_MS,
111
+ now
112
+ ])).rowCount ?? 0) > 0;
113
+ };
114
+ for (let delay = 5; !await acquire(); delay = Math.min(delay * 2, 200)) await releasedOrAfter(lockKey, delay);
115
+ const renew = setInterval(() => void pool.query(`UPDATE ${kv} SET expires_at = $3 WHERE key = $1 AND value = $2`, [
116
+ lockKey,
117
+ token,
118
+ Date.now() + LOCK_TTL_MS
119
+ ]).catch(() => {}), LOCK_TTL_MS / 3);
84
120
  try {
85
- await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [key]);
86
- try {
87
- return await fn();
88
- } finally {
89
- await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]);
90
- }
121
+ return await fn();
91
122
  } finally {
92
- client.release();
123
+ clearInterval(renew);
124
+ await pool.query(`WITH released AS (DELETE FROM ${kv} WHERE key = $1 AND value = $2 RETURNING key) SELECT pg_notify($3, $4) FROM released`, [
125
+ lockKey,
126
+ token,
127
+ PG_CHANNEL,
128
+ JSON.stringify({
129
+ channel: RELEASED,
130
+ message: lockKey
131
+ })
132
+ ]).catch(() => {});
93
133
  }
94
134
  });
95
135
  },
@@ -152,98 +192,5 @@ function postgresCluster(pool, options = {}) {
152
192
  }
153
193
  };
154
194
  }
155
- const UNLOCK = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
156
- const EXTEND = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
157
- /**
158
- * Cluster adapter backed by Redis (ioredis API). Pass two connections: one for
159
- * commands and one dedicated to subscriptions.
160
- */
161
- function redisCluster(opts) {
162
- const { client, subscriber } = opts;
163
- const prefix = opts.prefix ?? "matrix:";
164
- const channel = `${prefix}cluster`;
165
- const handlers = /* @__PURE__ */ new Map();
166
- const queue = localQueue();
167
- const ready = subscriber.subscribe(channel);
168
- subscriber.on("message", (ch, raw) => {
169
- if (ch !== channel) return;
170
- try {
171
- const { channel: name, message } = JSON.parse(raw);
172
- for (const h of handlers.get(name) ?? []) h(message);
173
- } catch {}
174
- });
175
- const k = (key) => `${prefix}${key}`;
176
- return {
177
- async nextId(name) {
178
- return Number(await client.incr(k(`seq:${name}`)));
179
- },
180
- async currentId(name) {
181
- return Number(await client.get(k(`seq:${name}`)) ?? 0);
182
- },
183
- async advance(name, value) {
184
- await client.eval(`local v = tonumber(redis.call("get", KEYS[1]) or "0") if v < tonumber(ARGV[1]) then redis.call("set", KEYS[1], ARGV[1]) end return 1`, 1, k(`seq:${name}`), value);
185
- },
186
- lock(key, fn) {
187
- return queue(key, async () => {
188
- const lockKey = k(`lock:${key}`);
189
- const token = randomUUID();
190
- const ttl = 3e4;
191
- for (let delay = 5;; delay = Math.min(delay * 2, 200)) {
192
- if (await client.set(lockKey, token, "PX", ttl, "NX") === "OK") break;
193
- await new Promise((r) => setTimeout(r, delay));
194
- }
195
- const renew = setInterval(() => void client.eval(EXTEND, 1, lockKey, token, ttl).catch(() => {}), ttl / 3);
196
- try {
197
- return await fn();
198
- } finally {
199
- clearInterval(renew);
200
- await client.eval(UNLOCK, 1, lockKey, token).catch(() => {});
201
- }
202
- });
203
- },
204
- async publish(name, message) {
205
- await ready;
206
- await client.publish(channel, JSON.stringify({
207
- channel: name,
208
- message
209
- }));
210
- },
211
- subscribe(name, handler) {
212
- let set = handlers.get(name);
213
- if (!set) handlers.set(name, set = /* @__PURE__ */ new Set());
214
- set.add(handler);
215
- },
216
- async get(key) {
217
- const v = await client.get(k(`kv:${key}`));
218
- return v === null ? null : JSON.parse(v);
219
- },
220
- async set(key, value, ttlMs) {
221
- if (ttlMs) await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs);
222
- else await client.set(k(`kv:${key}`), JSON.stringify(value));
223
- },
224
- async setIfAbsent(key, value, ttlMs) {
225
- return (ttlMs ? await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs, "NX") : await client.set(k(`kv:${key}`), JSON.stringify(value), "NX")) === "OK";
226
- },
227
- async delete(key) {
228
- await client.del(k(`kv:${key}`));
229
- },
230
- async entries(keyPrefix) {
231
- const out = [];
232
- let cursor = "0";
233
- const pattern = `${k(`kv:${keyPrefix}`).replace(/[*?[\]]/g, (c) => `\\${c}`)}*`;
234
- do {
235
- const [next, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
236
- cursor = next;
237
- if (keys.length) {
238
- const values = await client.mget(...keys);
239
- keys.forEach((key, i) => {
240
- if (values[i] !== null) out.push([key.slice(k("kv:").length), JSON.parse(values[i])]);
241
- });
242
- }
243
- } while (cursor !== "0");
244
- return out;
245
- }
246
- };
247
- }
248
195
  //#endregion
249
- export { localCluster, postgresCluster, redisCluster };
196
+ export { localCluster, postgresCluster };
@@ -4,11 +4,13 @@ import { M, UNSTABLE } from "../core/m.js";
4
4
  import { MatrixError, errors } from "../core/errors.js";
5
5
  import { LOCALPART_RE } from "../core/ids.js";
6
6
  import { toMsisdn } from "../core/phone.js";
7
+ import { escapeHtml } from "../core/html.js";
7
8
  import { canonicalAddress, isMedium } from "../core/threepid.js";
8
9
  import { boolean, looseObject, string } from "../vendor/zod/v4/classic/schemas.js";
9
10
  import { body, fail, lenient } from "../http/input.js";
10
11
  import { procedure } from "../http/procedure.js";
11
12
  import { requireUia } from "./uia.js";
13
+ import { checkRedirectUrl } from "../core/sso.js";
12
14
  import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
13
15
  //#region src/api/account.ts
14
16
  const deviceFields = {
@@ -146,10 +148,26 @@ function registerAccountRoutes(r) {
146
148
  if (!ctx.hs.oauth) throw errors.unrecognized("OAuth is not enabled");
147
149
  return { issuer: ctx.hs.oauth.options.issuer };
148
150
  } }), ["v1"]);
151
+ const ssoConfirmPage = (serverName, result) => {
152
+ const client = escapeHtml(result.client);
153
+ const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Continue to ${client}</title>
154
+ <style>body{font:16px system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem}a.button{display:inline-block;padding:.6rem 1.2rem;background:#0b7a55;color:#fff;border-radius:6px;text-decoration:none}</style>
155
+ <h1>Continue to ${client}?</h1>
156
+ <p>You signed in to <b>${escapeHtml(serverName)}</b>. <b>${client}</b> will get access to your account.</p>
157
+ <p>If you did not just sign in with this app, close this page.</p>
158
+ <p><a class="button" href="${escapeHtml(result.url)}">Continue to ${client}</a></p>`;
159
+ return new Response(html, { headers: {
160
+ "content-type": "text/html; charset=utf-8",
161
+ "cache-control": "no-store",
162
+ "referrer-policy": "no-referrer",
163
+ "x-frame-options": "DENY",
164
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
165
+ } });
166
+ };
149
167
  const ssoRedirect = async (ctx) => {
150
168
  if (!ctx.hs.sso) throw errors.unrecognized("SSO is not enabled");
151
- const redirectUrl = ctx.query.get("redirectUrl");
152
- if (!redirectUrl || !/^https?:\/\/|^[a-z][a-z0-9+.-]*:/i.test(redirectUrl)) throw errors.missingParam("Missing or invalid redirectUrl");
169
+ const redirectUrl = checkRedirectUrl(ctx.query.get("redirectUrl"));
170
+ if (!redirectUrl) throw errors.missingParam("Missing or invalid redirectUrl");
153
171
  return Response.redirect(await ctx.hs.sso.startLogin(ctx.params.idpId ?? null, redirectUrl), 302);
154
172
  };
155
173
  r.client("POST", "/login/get_token", procedure({
@@ -170,7 +188,8 @@ function registerAccountRoutes(r) {
170
188
  r.client("GET", "/login/sso/redirect/:idpId", procedure({ handler: ssoRedirect }));
171
189
  r.client("GET", "/login/sso/callback/:idpId", procedure({ handler: async (ctx) => {
172
190
  if (!ctx.hs.sso) throw errors.unrecognized("SSO is not enabled");
173
- return Response.redirect(await ctx.hs.sso.finishLogin(ctx.params.idpId, ctx.query), 302);
191
+ const result = await ctx.hs.sso.finishLogin(ctx.params.idpId, ctx.query);
192
+ return result.confirm ? ssoConfirmPage(ctx.hs.serverName, result) : Response.redirect(result.url, 302);
174
193
  } }));
175
194
  r.client("POST", "/login", procedure({
176
195
  rateLimit: "login",
package/dist/api/admin.js CHANGED
@@ -30,10 +30,13 @@ async function requireAdmin(ctx) {
30
30
  });
31
31
  return s;
32
32
  }
33
+ const adminGuard = async (ctx) => {
34
+ await requireAdmin(ctx);
35
+ return {};
36
+ };
33
37
  /** Admin HTTP API using Synapse-compatible paths, so existing tooling keeps working. */
34
38
  function registerAdminRoutes(r) {
35
39
  const notice = async (ctx) => {
36
- await requireAdmin(ctx);
37
40
  const body = ctx.input;
38
41
  return { event_id: await ctx.hs.admin.sendServerNotice(body.user_id, body.content, {
39
42
  type: body.type,
@@ -71,10 +74,12 @@ function registerAdminRoutes(r) {
71
74
  r.get("/_matrix/client/v1/admin/suspend/:userId", procedure({ handler: suspend.get }));
72
75
  r.put("/_matrix/client/v1/admin/suspend/:userId", procedure({ handler: suspend.put }));
73
76
  r.post("/_synapse/admin/v1/send_server_notice", procedure({
77
+ guard: adminGuard,
74
78
  input: noticeBody,
75
79
  handler: notice
76
80
  }));
77
81
  r.put("/_synapse/admin/v1/send_server_notice/:txnId", procedure({
82
+ guard: adminGuard,
78
83
  input: noticeBody,
79
84
  handler: notice
80
85
  }));
@@ -128,9 +133,9 @@ function registerAdminRoutes(r) {
128
133
  return ctx.hs.admin.getUser(userId);
129
134
  } }));
130
135
  r.post("/_synapse/admin/v1/deactivate/:userId", procedure({
136
+ guard: adminGuard,
131
137
  input: body({ erase: lenient(boolean()) }),
132
138
  handler: async (ctx) => {
133
- await requireAdmin(ctx);
134
139
  const body = ctx.input;
135
140
  await ctx.hs.admin.deactivateUser(ctx.params.userId, { erase: body.erase === true });
136
141
  return { id_server_unbind_result: "no-support" };
@@ -141,9 +146,9 @@ function registerAdminRoutes(r) {
141
146
  logout_devices: lenient(boolean())
142
147
  });
143
148
  r.post("/_synapse/admin/v1/reset_password/:userId", procedure({
149
+ guard: adminGuard,
144
150
  input: resetPasswordBody,
145
151
  handler: async (ctx) => {
146
- await requireAdmin(ctx);
147
152
  const body = ctx.input;
148
153
  await ctx.hs.admin.resetPassword(ctx.params.userId, body.new_password, body.logout_devices !== false);
149
154
  return {};
@@ -154,9 +159,9 @@ function registerAdminRoutes(r) {
154
159
  return { registration_tokens: await ctx.hs.admin.listRegistrationTokens() };
155
160
  } }));
156
161
  r.post("/_synapse/admin/v1/registration_tokens/new", procedure({
162
+ guard: adminGuard,
157
163
  input: registrationTokenBody,
158
164
  handler: async (ctx) => {
159
- await requireAdmin(ctx);
160
165
  const body = ctx.input;
161
166
  return ctx.hs.admin.createRegistrationToken({
162
167
  token: body.token,
package/dist/api/media.js CHANGED
@@ -53,7 +53,7 @@ async function remoteMedia(ctx) {
53
53
  }
54
54
  let fetched;
55
55
  try {
56
- fetched = await hs.federation.fetchMedia(serverName, mediaId);
56
+ fetched = await hs.federation.fetchMedia(serverName, mediaId, maxUpload(hs));
57
57
  } catch (err) {
58
58
  if (err instanceof MatrixError) throw err;
59
59
  throw new MatrixError(502, "M_UNKNOWN", "Could not fetch remote media");
@@ -1,40 +1,9 @@
1
1
  import { randomString } from "../core/crypto.js";
2
- import { errors } from "../core/errors.js";
2
+ import { MatrixError, errors } from "../core/errors.js";
3
+ import { publicGet } from "../core/outbound.js";
3
4
  import { procedure } from "../http/procedure.js";
4
- import { lookup } from "node:dns/promises";
5
- import { BlockList, isIP } from "node:net";
6
5
  //#region src/api/preview.ts
7
6
  const MAX_BYTES = 1048576;
8
- const privateRanges = new BlockList();
9
- for (const [net, prefix] of [
10
- ["0.0.0.0", 8],
11
- ["10.0.0.0", 8],
12
- ["100.64.0.0", 10],
13
- ["127.0.0.0", 8],
14
- ["169.254.0.0", 16],
15
- ["172.16.0.0", 12],
16
- ["192.0.0.0", 24],
17
- ["192.168.0.0", 16],
18
- ["198.18.0.0", 15],
19
- ["224.0.0.0", 4],
20
- ["240.0.0.0", 4]
21
- ]) privateRanges.addSubnet(net, prefix, "ipv4");
22
- for (const [net, prefix] of [
23
- ["::", 128],
24
- ["::1", 128],
25
- ["fc00::", 7],
26
- ["fe80::", 10],
27
- ["ff00::", 8],
28
- ["::ffff:0:0", 96]
29
- ]) privateRanges.addSubnet(net, prefix, "ipv6");
30
- async function assertPublicHost(hostname) {
31
- const host = hostname.replace(/^\[|\]$/g, "");
32
- const addresses = isIP(host) ? [{
33
- address: host,
34
- family: isIP(host)
35
- }] : await lookup(host, { all: true });
36
- for (const { address, family } of addresses) if (privateRanges.check(address, family === 6 ? "ipv6" : "ipv4")) throw errors.forbidden("URL resolves to a private address");
37
- }
38
7
  function decodeEntities(s) {
39
8
  return s.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (m) => ({
40
9
  "&amp;": "&",
@@ -72,42 +41,37 @@ async function preview(hs, rawUrl) {
72
41
  throw errors.invalidParam("Invalid URL");
73
42
  }
74
43
  if (url.protocol !== "http:" && url.protocol !== "https:") throw errors.invalidParam("Only http(s) URLs can be previewed");
75
- for (let redirects = 0; redirects < 5; redirects++) {
76
- if (!opts.allowPrivateAddresses) await assertPublicHost(url.hostname);
77
- const res = await fetch(url, {
78
- redirect: "manual",
79
- signal: AbortSignal.timeout(1e4),
80
- headers: { "user-agent": "matrix-nodejs-homeserver" }
81
- });
82
- if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
83
- url = new URL(res.headers.get("location"), url);
84
- continue;
85
- }
86
- if (!res.ok || !res.body) throw errors.notFound("Could not fetch URL");
87
- const type = res.headers.get("content-type") ?? "";
88
- if (type.startsWith("image/")) return {
89
- ...await storeImage(hs, res, type),
90
- "og:url": url.href
44
+ const get = opts.allowPrivateAddresses ? hs.fetch : hs.publicFetch;
45
+ const headers = { "user-agent": "matrix-nodejs-homeserver" };
46
+ const res = await publicGet(get, url, {
47
+ maxBytes: MAX_BYTES * 10,
48
+ truncate: true,
49
+ timeoutMs: 1e4,
50
+ headers
51
+ }).catch((err) => {
52
+ throw err instanceof MatrixError ? err : errors.notFound("Could not fetch URL");
53
+ });
54
+ if (res.status !== 200) throw errors.notFound("Could not fetch URL");
55
+ const type = res.headers.get("content-type") ?? "";
56
+ if (type.startsWith("image/")) {
57
+ if (res.body.length >= MAX_BYTES * 10) return {};
58
+ return {
59
+ ...await storeImage(hs, res.body, type),
60
+ "og:url": res.url.href
91
61
  };
92
- if (!type.includes("html")) return {};
93
- let size = 0;
94
- const chunks = [];
95
- for await (const chunk of res.body) {
96
- size += chunk.length;
97
- if (size > MAX_BYTES) break;
98
- chunks.push(chunk);
99
- }
100
- const og = parseOpenGraph(Buffer.concat(chunks).toString("utf8"));
101
- if (og["og:image"]) try {
102
- const imageUrl = new URL(og["og:image"], url);
103
- if (!opts.allowPrivateAddresses) await assertPublicHost(imageUrl.hostname);
104
- const img = await fetch(imageUrl, { signal: AbortSignal.timeout(1e4) });
105
- const imgType = img.headers.get("content-type") ?? "";
106
- if (img.ok && imgType.startsWith("image/")) Object.assign(og, await storeImage(hs, img, imgType));
107
- } catch {}
108
- return og;
109
62
  }
110
- throw errors.notFound("Too many redirects");
63
+ if (!type.includes("html")) return {};
64
+ const og = parseOpenGraph(res.body.subarray(0, MAX_BYTES).toString("utf8"));
65
+ if (og["og:image"]) try {
66
+ const img = await publicGet(get, new URL(og["og:image"], res.url), {
67
+ maxBytes: MAX_BYTES * 10,
68
+ timeoutMs: 1e4,
69
+ headers
70
+ });
71
+ const imgType = img.headers.get("content-type") ?? "";
72
+ if (img.status === 200 && imgType.startsWith("image/")) Object.assign(og, await storeImage(hs, img.body, imgType));
73
+ } catch {}
74
+ return og;
111
75
  }
112
76
  /** Read width/height from PNG, GIF, JPEG or WebP headers. */
113
77
  function imageSize(data) {
@@ -157,9 +121,8 @@ function imageSize(data) {
157
121
  return null;
158
122
  }
159
123
  /** Store a fetched image in the media repository and describe it for a preview. */
160
- async function storeImage(hs, res, type) {
161
- const data = new Uint8Array(await res.arrayBuffer());
162
- if (data.length > MAX_BYTES * 10) return {};
124
+ async function storeImage(hs, body, type) {
125
+ const data = new Uint8Array(body);
163
126
  const mediaId = randomString(24);
164
127
  await hs.media.put(mediaId, data, {
165
128
  contentType: type,