matrix-nodejs-homeserver 0.3.1 → 0.3.3
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 +89 -6
- package/dist/adapters/cluster.d.ts +1 -26
- package/dist/adapters/cluster.js +50 -103
- package/dist/api/account.js +22 -3
- package/dist/api/admin.js +9 -4
- package/dist/api/media.js +1 -1
- package/dist/api/preview.js +33 -70
- package/dist/api/slidingsync.js +2 -2
- package/dist/api/sync.js +402 -134
- package/dist/core/cluster.d.ts +12 -5
- package/dist/core/cluster.js +46 -29
- package/dist/core/html.js +13 -0
- package/dist/core/identity.js +1 -1
- package/dist/core/json.js +10 -1
- package/dist/core/keys.d.ts +10 -1
- package/dist/core/keys.js +60 -41
- package/dist/core/outbound.d.ts +16 -0
- package/dist/core/outbound.js +172 -0
- package/dist/core/push.js +52 -34
- package/dist/core/pushrules.js +24 -5
- package/dist/core/ratelimit.d.ts +1 -1
- package/dist/core/rooms.d.ts +10 -1
- package/dist/core/rooms.js +25 -0
- package/dist/core/semaphore.js +23 -0
- package/dist/core/sso.d.ts +15 -1
- package/dist/core/sso.js +38 -2
- package/dist/core/threepid.js +1 -9
- package/dist/federation/client.js +7 -0
- package/dist/federation/index.d.ts +1 -1
- package/dist/federation/index.js +13 -6
- package/dist/homeserver.d.ts +21 -3
- package/dist/homeserver.js +13 -4
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/storage/batch.js +82 -0
- package/dist/storage/memory.js +1 -0
- package/dist/storage/schema.js +8 -3
- package/dist/storage/sql.d.ts +22 -3
- package/dist/storage/sql.js +138 -21
- package/dist/storage/types.d.ts +30 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# matrix-nodejs-homeserver
|
|
2
2
|
|
|
3
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
5
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
6
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
7
|
+
[](https://github.com/k18k/matrix-nodejs-homeserver/actions/workflows/loadtest.yml)
|
|
8
|
+
|
|
3
9
|
A [Matrix](https://matrix.org) homeserver as an importable Node.js library. It isn't
|
|
4
10
|
tied to any HTTP framework, database, auth system or deployment shape. You can use
|
|
5
11
|
it for a demo on SQLite, embed it in a monolith next to your existing users, or run
|
|
6
12
|
it as a horizontally scaled chat backend that replaces Slack or Teams.
|
|
7
13
|
|
|
8
|
-
- Node.js **26
|
|
14
|
+
- 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
15
|
- Client-Server API, Server-Server API (federation), end-to-end encryption, sliding sync, VoIP/MatrixRTC and push
|
|
10
16
|
- Runs on `node:http`, Express, Fastify, Hono or any fetch runtime
|
|
11
17
|
- Stores data in SQLite, PostgreSQL, MySQL, memory or your own adapter
|
|
@@ -60,12 +66,21 @@ mysqlAdapter(mysql.createPool(uri)); // mysql2/promise
|
|
|
60
66
|
memoryAdapter(); // tests / demos
|
|
61
67
|
```
|
|
62
68
|
|
|
69
|
+
SQLite runs in WAL mode with `synchronous = NORMAL`: a commit survives a crash of the process,
|
|
70
|
+
but a power loss can roll back the last few. Pass `sqliteAdapter(db, { synchronous: "full" })`
|
|
71
|
+
to wait for the disk on every commit, at the cost of blocking the event loop each time.
|
|
72
|
+
|
|
63
73
|
Tables are created on startup. Writes for each event are atomic: SQLite uses
|
|
64
74
|
`BEGIN IMMEDIATE`, Postgres and MySQL use a dedicated pooled connection, and the
|
|
65
75
|
memory adapter keeps an undo log.
|
|
66
76
|
|
|
77
|
+
The Postgres adapter prepares its statements on each connection and reuses them. Behind a
|
|
78
|
+
pooler that does not support prepared statements (PgBouncer before 1.21, or in transaction
|
|
79
|
+
mode without `max_prepared_statements`), pass `postgresAdapter(pool, { preparedStatements: false })`.
|
|
80
|
+
|
|
67
81
|
To use another store, implement `DatabaseAdapter` (`create`, `upsert`, `findOne`,
|
|
68
|
-
`findMany`, `update`, `delete`, `max`, and optionally `migrate
|
|
82
|
+
`findMany`, `update`, `delete`, `max`, and optionally `migrate`, `transaction`,
|
|
83
|
+
`findManyBatch` and `upsertMany`).
|
|
69
84
|
The exported `schema` describes every model. If your driver can run parameterised
|
|
70
85
|
SQL, `sqlAdapter({ dialect, executor, transaction })` handles the SQL generation for you.
|
|
71
86
|
|
|
@@ -75,12 +90,12 @@ By default a homeserver assumes it is the only process using its database. To ru
|
|
|
75
90
|
several instances behind a load balancer, give them a shared **cluster adapter**:
|
|
76
91
|
|
|
77
92
|
```ts
|
|
78
|
-
import { postgresCluster
|
|
93
|
+
import { postgresCluster } from "matrix-nodejs-homeserver";
|
|
79
94
|
|
|
80
95
|
createHomeserver({
|
|
81
96
|
homeserver: "example.com",
|
|
82
97
|
databaseAdapter: postgresAdapter(pool),
|
|
83
|
-
cluster: postgresCluster(pool),
|
|
98
|
+
cluster: postgresCluster(pool),
|
|
84
99
|
});
|
|
85
100
|
```
|
|
86
101
|
|
|
@@ -95,8 +110,7 @@ The cluster adapter provides:
|
|
|
95
110
|
- **Shared ephemeral state:** UIA sessions, SSO logins and transaction deduplication.
|
|
96
111
|
|
|
97
112
|
[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
|
|
99
|
-
for coordination). It also shows a media adapter backed by that database, since local
|
|
113
|
+
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
114
|
disks differ between machines.
|
|
101
115
|
|
|
102
116
|
The database adapter has to support concurrent access from several processes, so
|
|
@@ -130,6 +144,9 @@ createHomeserver({
|
|
|
130
144
|
clientId, clientSecret, mapUser: (claims) => ({ localpart: claims.email.split("@")[0] }) }] },
|
|
131
145
|
publicBaseUrl: "https://matrix.example.com",
|
|
132
146
|
```
|
|
147
|
+
After signing in, users confirm the client the login token goes to, on a page
|
|
148
|
+
that names it. Clients you trust skip that page:
|
|
149
|
+
`sso: { providers, clientAllowlist: ["https://app.element.io/"] }`.
|
|
133
150
|
- **Delegated OAuth 2.0** (the Matrix OAuth API, e.g. with
|
|
134
151
|
matrix-authentication-service, Keycloak or Authentik). The provider owns the
|
|
135
152
|
accounts, and the homeserver validates its access tokens by introspection:
|
|
@@ -328,6 +345,7 @@ paths (server admins only), so existing tools keep working.
|
|
|
328
345
|
| `publicBaseUrl` | Advertised via `/.well-known/matrix/client`. Required for SSO. |
|
|
329
346
|
| `oauth`, `sso` | Delegated OAuth or upstream OIDC SSO. |
|
|
330
347
|
| `federation` | `{ enabled, verifyTls, allowedServers, blockedServers, fasterJoins }`. Off unless `enabled: true`. |
|
|
348
|
+
| `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
349
|
| `rtc` | `{ turn, transports }` |
|
|
332
350
|
| `delayedEvents` | `{ maxDelayMs, maxPerUser }` |
|
|
333
351
|
| `rateLimits` | `false`, or `{ limiter, rules }`. In-memory token buckets by default. Plug in a shared limiter when running a cluster. |
|
|
@@ -451,6 +469,8 @@ Live status, updated after every Complement run (full or targeted) with
|
|
|
451
469
|
|
|
452
470
|
| Run | Top-level | Subtests | Note |
|
|
453
471
|
|---|---|---|---|
|
|
472
|
+
| 10 | 214 / 214 | 851 / 851 | load-test fixes #25–#35: batched /sync reads, sync concurrency limit, Postgres lock leases, group commit (248ac8d) |
|
|
473
|
+
| 9 | 214 / 214 | 851 / 851 | zod, procedures, M/UNSTABLE, SSO confirmation, outbound address policy, admin guard (ec5aa79) |
|
|
454
474
|
| 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
475
|
| 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
476
|
| 6 | 189 / 214 | 803 / 851 | Partial-state joins (MSC3902): all subtests pass |
|
|
@@ -471,6 +491,69 @@ Live status, updated after every Complement run (full or targeted) with
|
|
|
471
491
|
CI runs the whole suite with the same script. `complement/passing.txt` lists the tests
|
|
472
492
|
(and subtests) that are known to pass, and CI fails if any of them regress.
|
|
473
493
|
|
|
494
|
+
### Load testing
|
|
495
|
+
|
|
496
|
+
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
|
|
497
|
+
homeserver that reports, every 10 s, requests per second, memory, event-loop delay and
|
|
498
|
+
(with Postgres) how many queries wait for a pool connection and how long locks are held.
|
|
499
|
+
|
|
500
|
+
The scenario: `ONLINE_USERS` each hold a long-polling `/sync` open in one of `ROOMS` rooms,
|
|
501
|
+
while `MESSAGES_PER_SECOND` messages are sent in total. Every user registers and joins
|
|
502
|
+
through the client API. It measures each request type and the time from sending a message to
|
|
503
|
+
its arrival in other users' `/sync` (`message_delivery`).
|
|
504
|
+
|
|
505
|
+
```sh
|
|
506
|
+
# Postgres with N homeserver nodes behind nginx (round-robin, no sticky sessions)
|
|
507
|
+
ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 RAMP=30s DURATION=30s \
|
|
508
|
+
docker compose -p matrix-loadtest -f loadtest/docker-compose.yml up --build \
|
|
509
|
+
--scale homeserver=5 --abort-on-container-exit --exit-code-from k6 k6
|
|
510
|
+
# CLUSTER=0: one process on Postgres without a cluster adapter
|
|
511
|
+
|
|
512
|
+
# SQLite, one process, no Docker (needs k6 installed)
|
|
513
|
+
DATA_DIR=/tmp/lt node loadtest/server.ts &
|
|
514
|
+
BASE_URL=http://localhost:8008 ONLINE_USERS=1000 MESSAGES_PER_SECOND=50 ROOMS=25 k6 run loadtest/k6/matrix.js
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
<!-- loadtest-results:start -->
|
|
518
|
+
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
|
|
519
|
+
scenario on one GitHub runner (4 CPUs), with k6 on the same machine. Each run's job summary has
|
|
520
|
+
the full table.
|
|
521
|
+
|
|
522
|
+
Latest results: 1000 users online in 25 rooms (40 per room), 50 messages/s, 30 s ramp-up then
|
|
523
|
+
30 s at full load, on one developer machine (WSL2, Docker Desktop). Times are p50 / p95.
|
|
524
|
+
|
|
525
|
+
| Setup | req/s | send | /sync | join | delivery | messages delivered | failed |
|
|
526
|
+
|---|---|---|---|---|---|---|---|
|
|
527
|
+
| SQLite, 1 process | 662 | 97 ms / 0.16 s | 0.87 s / 1.6 s | 10 ms / 91 ms | 1.64 s / 2.70 s | 53,935 | 0% |
|
|
528
|
+
| Postgres, no cluster | 670 | 15 ms / 19 ms | 1.1 s / 1.8 s | 14 ms / 23 ms | 1.76 s / 2.31 s | 58,667 | 0% |
|
|
529
|
+
| Postgres, cluster, 1 node | 593 | 27 ms / 38 ms | 1.3 s / 2.0 s | 36 ms / 51 ms | 1.96 s / 2.57 s | 58,493 | 0% |
|
|
530
|
+
| Postgres, cluster, 5 nodes | 871 | 60 ms / 88 ms | 0.50 s / 1.1 s | 62 ms / 119 ms | 0.70 s / 1.09 s | 59,298 | 0% |
|
|
531
|
+
|
|
532
|
+
What the load tests found and fixed so far: presence written on every `/sync` (#26), one commit
|
|
533
|
+
per push notification (#27), a missing index (#28), Postgres cluster locks that could deadlock
|
|
534
|
+
the pool (#29), stream writes serialized per instance (#30), `/sync` reading every change on the
|
|
535
|
+
server (#31) and every joined room (#33), lock handover by polling (#32). Then `/sync` reads were batched
|
|
536
|
+
(#34) and a process builds at most four sync responses at once (#35), so sends no longer queue
|
|
537
|
+
behind the syncs a message wakes.
|
|
538
|
+
|
|
539
|
+
Since 0.3.2 (#38–#45), Postgres delivery time fell by about 55% on one node and 50% on five:
|
|
540
|
+
- changed rooms are built concurrently, with fewer reads each (#38);
|
|
541
|
+
- push notifications of a message are written in one statement (#39);
|
|
542
|
+
- Postgres statements are prepared once per connection (#40);
|
|
543
|
+
- push rules are no longer copied per member (#41);
|
|
544
|
+
- to-device acknowledgements run only when needed (#42);
|
|
545
|
+
- reads issued at the same moment by concurrent requests share one statement (#43);
|
|
546
|
+
- presence and device-list changes are found through a stream index (#44);
|
|
547
|
+
- batched reads take half as long to plan (#45).
|
|
548
|
+
|
|
549
|
+
On SQLite, commits no longer wait for an fsync (#46). Database calls also yield to the event
|
|
550
|
+
loop (#47): before that, a wave of syncs could hold it for seconds, sends took 0.74 s and the
|
|
551
|
+
server took in only about 40% of the messages the scenario sends.
|
|
552
|
+
|
|
553
|
+
With every user receiving about two messages a second, delivery time is how long it takes a
|
|
554
|
+
process to rebuild every user's `/sync` in turn: about 750 per second per Postgres node here.
|
|
555
|
+
<!-- loadtest-results:end -->
|
|
556
|
+
|
|
474
557
|
## License
|
|
475
558
|
|
|
476
559
|
[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,
|
|
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 };
|
package/dist/adapters/cluster.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
196
|
+
export { localCluster, postgresCluster };
|
package/dist/api/account.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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");
|
package/dist/api/preview.js
CHANGED
|
@@ -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
|
"&": "&",
|
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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,
|
|
161
|
-
const data = new Uint8Array(
|
|
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,
|
package/dist/api/slidingsync.js
CHANGED
|
@@ -421,10 +421,10 @@ async function buildExtensions(hs, s, ext, since, to, roomIds, next) {
|
|
|
421
421
|
else if (roomIds.includes(r.room_id) || since !== null) (rooms[r.room_id] ??= []).push(ev);
|
|
422
422
|
}
|
|
423
423
|
if (since === null && !global.some((e) => e.type === M.PUSH_RULES)) {
|
|
424
|
-
const {
|
|
424
|
+
const { readOnlyRules } = await import("../core/pushrules.js");
|
|
425
425
|
global.push({
|
|
426
426
|
type: M.PUSH_RULES,
|
|
427
|
-
content: { global: await
|
|
427
|
+
content: { global: await readOnlyRules(hs, userId) }
|
|
428
428
|
});
|
|
429
429
|
}
|
|
430
430
|
out.account_data = {
|