matrix-nodejs-homeserver 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +457 -2
  3. package/dist/adapters/auth.d.ts +50 -0
  4. package/dist/adapters/auth.js +18 -0
  5. package/dist/adapters/cluster.d.ts +55 -0
  6. package/dist/adapters/cluster.js +249 -0
  7. package/dist/adapters/media.d.ts +50 -0
  8. package/dist/adapters/media.js +63 -0
  9. package/dist/adapters/s3.d.ts +33 -0
  10. package/dist/adapters/s3.js +95 -0
  11. package/dist/api/account.js +560 -0
  12. package/dist/api/admin.js +251 -0
  13. package/dist/api/appservice.js +33 -0
  14. package/dist/api/backup.js +269 -0
  15. package/dist/api/federation.js +792 -0
  16. package/dist/api/filters.js +87 -0
  17. package/dist/api/index.js +47 -0
  18. package/dist/api/legacy.js +170 -0
  19. package/dist/api/media.js +255 -0
  20. package/dist/api/misc.js +86 -0
  21. package/dist/api/preview.js +200 -0
  22. package/dist/api/profile.js +165 -0
  23. package/dist/api/push.js +187 -0
  24. package/dist/api/relationships.js +264 -0
  25. package/dist/api/rooms.js +1364 -0
  26. package/dist/api/rtc.d.ts +24 -0
  27. package/dist/api/rtc.js +52 -0
  28. package/dist/api/search.js +155 -0
  29. package/dist/api/slidingsync.js +464 -0
  30. package/dist/api/spaces.js +172 -0
  31. package/dist/api/sync.js +600 -0
  32. package/dist/api/threadsubs.js +129 -0
  33. package/dist/api/threepid.js +122 -0
  34. package/dist/api/uia.js +126 -0
  35. package/dist/api/user.js +286 -0
  36. package/dist/core/admin.d.ts +85 -0
  37. package/dist/core/admin.js +307 -0
  38. package/dist/core/adminroom.d.ts +49 -0
  39. package/dist/core/adminroom.js +482 -0
  40. package/dist/core/appservices.d.ts +73 -0
  41. package/dist/core/appservices.js +368 -0
  42. package/dist/core/audit.d.ts +51 -0
  43. package/dist/core/audit.js +86 -0
  44. package/dist/core/auth.d.ts +48 -0
  45. package/dist/core/auth.js +237 -0
  46. package/dist/core/authrules.d.ts +4 -0
  47. package/dist/core/authrules.js +347 -0
  48. package/dist/core/cluster.d.ts +66 -0
  49. package/dist/core/cluster.js +191 -0
  50. package/dist/core/crypto.d.ts +10 -0
  51. package/dist/core/crypto.js +100 -0
  52. package/dist/core/delayed.d.ts +39 -0
  53. package/dist/core/delayed.js +159 -0
  54. package/dist/core/errors.d.ts +9 -0
  55. package/dist/core/errors.js +45 -0
  56. package/dist/core/events.d.ts +32 -0
  57. package/dist/core/events.js +185 -0
  58. package/dist/core/glob.js +66 -0
  59. package/dist/core/identity.d.ts +24 -0
  60. package/dist/core/identity.js +96 -0
  61. package/dist/core/ids.js +36 -0
  62. package/dist/core/invitefilter.js +35 -0
  63. package/dist/core/json.js +16 -0
  64. package/dist/core/keys.d.ts +36 -0
  65. package/dist/core/keys.js +449 -0
  66. package/dist/core/notifier.d.ts +17 -0
  67. package/dist/core/notifier.js +54 -0
  68. package/dist/core/oauth.d.ts +38 -0
  69. package/dist/core/oauth.js +108 -0
  70. package/dist/core/partialstate.d.ts +32 -0
  71. package/dist/core/partialstate.js +197 -0
  72. package/dist/core/phone.js +268 -0
  73. package/dist/core/policy.d.ts +29 -0
  74. package/dist/core/policy.js +96 -0
  75. package/dist/core/profile.js +39 -0
  76. package/dist/core/push.d.ts +47 -0
  77. package/dist/core/push.js +306 -0
  78. package/dist/core/pushrules.d.ts +1 -0
  79. package/dist/core/pushrules.js +228 -0
  80. package/dist/core/ratelimit.d.ts +23 -0
  81. package/dist/core/ratelimit.js +77 -0
  82. package/dist/core/rooms.d.ts +215 -0
  83. package/dist/core/rooms.js +1729 -0
  84. package/dist/core/settings.d.ts +11 -0
  85. package/dist/core/settings.js +29 -0
  86. package/dist/core/sso.d.ts +46 -0
  87. package/dist/core/sso.js +165 -0
  88. package/dist/core/stateres.d.ts +4 -0
  89. package/dist/core/stateres.js +208 -0
  90. package/dist/core/threepid.d.ts +80 -0
  91. package/dist/core/threepid.js +275 -0
  92. package/dist/core/typing.d.ts +16 -0
  93. package/dist/core/typing.js +60 -0
  94. package/dist/federation/client.d.ts +51 -0
  95. package/dist/federation/client.js +278 -0
  96. package/dist/federation/index.d.ts +153 -0
  97. package/dist/federation/index.js +1058 -0
  98. package/dist/federation/keyring.d.ts +26 -0
  99. package/dist/federation/keyring.js +122 -0
  100. package/dist/federation/sender.d.ts +26 -0
  101. package/dist/federation/sender.js +164 -0
  102. package/dist/homeserver.d.ts +223 -0
  103. package/dist/homeserver.js +301 -0
  104. package/dist/http/adapters.d.ts +40 -0
  105. package/dist/http/adapters.js +130 -0
  106. package/dist/http/handler.d.ts +8 -0
  107. package/dist/http/handler.js +202 -0
  108. package/dist/http/input.js +24 -0
  109. package/dist/http/router.d.ts +60 -0
  110. package/dist/http/router.js +76 -0
  111. package/dist/index.d.ts +25 -0
  112. package/dist/index.js +14 -0
  113. package/dist/storage/memory.d.ts +5 -0
  114. package/dist/storage/memory.js +122 -0
  115. package/dist/storage/schema.d.ts +4 -0
  116. package/dist/storage/schema.js +664 -0
  117. package/dist/storage/sql.d.ts +72 -0
  118. package/dist/storage/sql.js +274 -0
  119. package/dist/storage/types.d.ts +55 -0
  120. package/package.json +67 -4
@@ -0,0 +1,275 @@
1
+ import { randomString } from "./crypto.js";
2
+ import { MatrixError, errors } from "./errors.js";
3
+ import { toMsisdn } from "./phone.js";
4
+ import { randomInt } from "node:crypto";
5
+ //#region src/core/threepid.ts
6
+ const CLIENT_SECRET_RE = /^[0-9a-zA-Z.=_-]{1,255}$/;
7
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
8
+ const SUBMIT_PATH = "/_matrix/client/unstable/threepid/submit_token";
9
+ const PURPOSE_TEXT = {
10
+ register: "create your account",
11
+ password_reset: "reset your password",
12
+ add_threepid: "add this address to your account"
13
+ };
14
+ function isMedium(m) {
15
+ return m === "email" || m === "msisdn";
16
+ }
17
+ /** Canonical form of an address: lower-case email, digits-only phone number. */
18
+ function canonicalAddress(medium, address) {
19
+ if (medium === "email") {
20
+ const email = address.trim().toLowerCase();
21
+ return EMAIL_RE.test(email) && email.length <= 320 ? email : null;
22
+ }
23
+ const msisdn = address.replace(/^\+/, "");
24
+ return /^\d{7,15}$/.test(msisdn) ? msisdn : null;
25
+ }
26
+ var ThreepidService = class {
27
+ hs;
28
+ constructor(hs) {
29
+ this.hs = hs;
30
+ }
31
+ get options() {
32
+ return this.hs.config.threepid;
33
+ }
34
+ /** Whether this server can validate addresses of `medium` itself. */
35
+ canValidate(medium) {
36
+ return medium === "email" ? !!this.options.sendEmail : !!this.options.sendSms;
37
+ }
38
+ /** Account changes involving 3PIDs are possible at all (the m.3pid_changes capability). */
39
+ get enabled() {
40
+ return this.canValidate("email") || this.canValidate("msisdn");
41
+ }
42
+ async userFor(medium, address) {
43
+ return (await this.hs.db.findOne("user_threepids", [{
44
+ field: "medium",
45
+ value: medium
46
+ }, {
47
+ field: "address",
48
+ value: address
49
+ }]))?.user_id ?? null;
50
+ }
51
+ async list(userId) {
52
+ return (await this.hs.db.findMany("user_threepids", { where: [{
53
+ field: "user_id",
54
+ value: userId
55
+ }] })).map((r) => ({
56
+ medium: r.medium,
57
+ address: r.address,
58
+ validated_at: r.validated_ts,
59
+ added_at: r.added_ts
60
+ }));
61
+ }
62
+ async add(userId, threepid) {
63
+ const owner = await this.userFor(threepid.medium, threepid.address);
64
+ if (owner && owner !== userId) throw new MatrixError(400, "M_THREEPID_IN_USE", "This address is already in use");
65
+ await this.hs.db.upsert("user_threepids", {
66
+ user_id: userId,
67
+ medium: threepid.medium,
68
+ address: threepid.address,
69
+ validated_ts: threepid.validatedTs,
70
+ added_ts: Date.now()
71
+ });
72
+ }
73
+ async remove(userId, medium, address) {
74
+ return await this.hs.db.delete("user_threepids", [
75
+ {
76
+ field: "user_id",
77
+ value: userId
78
+ },
79
+ {
80
+ field: "medium",
81
+ value: medium
82
+ },
83
+ {
84
+ field: "address",
85
+ value: address
86
+ }
87
+ ]) > 0;
88
+ }
89
+ /**
90
+ * Unbind a 3PID from the identity servers it was bound on through this server (or `idServer`).
91
+ * Returns "success" if every unbind succeeded, "no-support" otherwise.
92
+ */
93
+ async unbindAll(userId, medium, address, idServer) {
94
+ const where = [
95
+ {
96
+ field: "user_id",
97
+ value: userId
98
+ },
99
+ {
100
+ field: "medium",
101
+ value: medium
102
+ },
103
+ {
104
+ field: "address",
105
+ value: address
106
+ }
107
+ ];
108
+ const servers = idServer ? [idServer] : (await this.hs.db.findMany("threepid_bindings", { where })).map((b) => b.id_server);
109
+ if (servers.length === 0) return "no-support";
110
+ let result = "success";
111
+ for (const server of servers) {
112
+ let ok = false;
113
+ try {
114
+ ok = await this.hs.identityServers.unbind(server, userId, medium, address);
115
+ } catch (err) {
116
+ this.hs.logger.warn("Unbind from", server, "failed", err);
117
+ }
118
+ if (ok) await this.hs.db.delete("threepid_bindings", [...where, {
119
+ field: "id_server",
120
+ value: server
121
+ }]);
122
+ else result = "no-support";
123
+ }
124
+ return result;
125
+ }
126
+ /** Remove every 3PID of a (deactivated) account, unbinding them from identity servers. */
127
+ async removeAll(userId) {
128
+ let result = "success";
129
+ const bound = await this.hs.db.findMany("threepid_bindings", { where: [{
130
+ field: "user_id",
131
+ value: userId
132
+ }] });
133
+ for (const b of bound) if (await this.unbindAll(userId, b.medium, b.address, b.id_server) !== "success") result = "no-support";
134
+ await this.hs.db.delete("user_threepids", [{
135
+ field: "user_id",
136
+ value: userId
137
+ }]);
138
+ return bound.length ? result : "no-support";
139
+ }
140
+ /**
141
+ * Start (or continue) validating an address: `POST .../{medium}/requestToken`. Repeating a
142
+ * request with the same `client_secret`, address and `send_attempt` does not send again.
143
+ */
144
+ async requestToken(purpose, medium, body) {
145
+ if (!this.enabled) throw errors.threepidDenied();
146
+ if (!this.canValidate(medium)) throw new MatrixError(400, "M_THREEPID_MEDIUM_NOT_SUPPORTED", `This server cannot validate ${medium === "email" ? "email addresses" : "phone numbers"}`);
147
+ if (typeof body.client_secret !== "string" || !CLIENT_SECRET_RE.test(body.client_secret)) throw errors.invalidParam("Invalid client_secret");
148
+ if (!Number.isInteger(body.send_attempt)) throw errors.invalidParam("send_attempt must be an integer");
149
+ let address;
150
+ if (medium === "email") {
151
+ if (typeof body.email !== "string") throw errors.missingParam("Missing email");
152
+ address = canonicalAddress("email", body.email);
153
+ if (!address) throw errors.invalidParam("Invalid email address");
154
+ } else {
155
+ if (typeof body.country !== "string" || typeof body.phone_number !== "string") throw errors.missingParam("Missing country or phone_number");
156
+ address = toMsisdn(body.country, body.phone_number);
157
+ if (!address) throw errors.invalidParam("Invalid phone number");
158
+ }
159
+ const owner = await this.userFor(medium, address);
160
+ if (purpose === "password_reset" && !owner) throw new MatrixError(400, "M_THREEPID_NOT_FOUND", "No account uses this address");
161
+ if (purpose !== "password_reset" && owner) throw new MatrixError(400, "M_THREEPID_IN_USE", "This address is already in use");
162
+ const nextLink = typeof body.next_link === "string" ? body.next_link : null;
163
+ if (nextLink && !/^https?:\/\//i.test(nextLink)) throw errors.invalidParam("next_link must be an http(s) URL");
164
+ const existing = (await this.hs.db.findMany("threepid_sessions", { where: [{
165
+ field: "client_secret",
166
+ value: body.client_secret
167
+ }] })).find((s) => s.medium === medium && s.address === address && !this.expired(s));
168
+ let session = existing;
169
+ if (existing && body.send_attempt <= existing.send_attempt) return this.tokenResponse(existing);
170
+ if (existing) await this.hs.db.update("threepid_sessions", [{
171
+ field: "sid",
172
+ value: existing.sid
173
+ }], {
174
+ send_attempt: body.send_attempt,
175
+ next_link: nextLink
176
+ });
177
+ else {
178
+ session = {
179
+ sid: randomString(24),
180
+ client_secret: body.client_secret,
181
+ medium,
182
+ address,
183
+ send_attempt: body.send_attempt,
184
+ next_link: nextLink,
185
+ token: medium === "email" ? randomString(32) : String(randomInt(0, 1e6)).padStart(6, "0"),
186
+ created_ts: Date.now(),
187
+ validated_ts: null
188
+ };
189
+ await this.hs.db.create("threepid_sessions", session);
190
+ }
191
+ await this.send(purpose, session);
192
+ return this.tokenResponse(session);
193
+ }
194
+ tokenResponse(session) {
195
+ const out = { sid: session.sid };
196
+ if (this.hs.config.publicBaseUrl) out.submit_url = `${this.hs.config.publicBaseUrl.replace(/\/$/, "")}${SUBMIT_PATH}`;
197
+ if (session.medium === "msisdn") Object.assign(out, {
198
+ msisdn: session.address,
199
+ intl_fmt: `+${session.address}`
200
+ });
201
+ return out;
202
+ }
203
+ async send(purpose, session) {
204
+ const what = PURPOSE_TEXT[purpose];
205
+ try {
206
+ if (session.medium === "email") {
207
+ const link = `${this.hs.config.publicBaseUrl?.replace(/\/$/, "") ?? ""}${SUBMIT_PATH}?${new URLSearchParams({
208
+ sid: session.sid,
209
+ client_secret: session.client_secret,
210
+ token: session.token
211
+ })}`;
212
+ await this.options.sendEmail({
213
+ to: session.address,
214
+ purpose,
215
+ token: session.token,
216
+ link,
217
+ subject: `Confirm your email address for ${this.hs.serverName}`,
218
+ text: `To ${what} on ${this.hs.serverName}, open this link:\n\n${link}\n\nIf you did not request this, ignore this message.`,
219
+ html: `<p>To ${what} on ${escapeHtml(this.hs.serverName)}, <a href="${escapeHtml(link)}">confirm your email address</a>.</p><p>If you did not request this, ignore this message.</p>`
220
+ });
221
+ } else await this.options.sendSms({
222
+ to: session.address,
223
+ purpose,
224
+ token: session.token,
225
+ text: `Your ${this.hs.serverName} code to ${what}: ${session.token}`
226
+ });
227
+ } catch (err) {
228
+ this.hs.logger.warn("Sending a 3PID validation message failed", err);
229
+ throw new MatrixError(500, "M_UNKNOWN", "Could not send the validation message");
230
+ }
231
+ }
232
+ expired(session) {
233
+ return session.created_ts + (this.options.sessionLifetimeMs ?? 864e5) < Date.now();
234
+ }
235
+ /** Validate a session with the token the user received. Returns the session's `next_link`. */
236
+ async submitToken(sid, clientSecret, token) {
237
+ const session = await this.hs.db.findOne("threepid_sessions", [{
238
+ field: "sid",
239
+ value: sid
240
+ }]);
241
+ if (!session || session.client_secret !== clientSecret || this.expired(session)) throw new MatrixError(400, "M_NO_VALID_SESSION", "Unknown or expired session");
242
+ if (session.token !== token.trim()) throw new MatrixError(400, "M_INVALID_PARAM", "Invalid token");
243
+ if (session.validated_ts === null) await this.hs.db.update("threepid_sessions", [{
244
+ field: "sid",
245
+ value: sid
246
+ }], { validated_ts: Date.now() });
247
+ return session.next_link;
248
+ }
249
+ /** The address a client proved ownership of, given its `threepid_creds`. */
250
+ async validated(creds) {
251
+ const c = creds;
252
+ if (!c || typeof c.sid !== "string" || typeof c.client_secret !== "string") return null;
253
+ const session = await this.hs.db.findOne("threepid_sessions", [{
254
+ field: "sid",
255
+ value: c.sid
256
+ }]);
257
+ if (!session || session.client_secret !== c.client_secret || session.validated_ts === null || this.expired(session)) return null;
258
+ return {
259
+ medium: session.medium,
260
+ address: session.address,
261
+ validatedTs: session.validated_ts
262
+ };
263
+ }
264
+ };
265
+ function escapeHtml(s) {
266
+ return s.replace(/[&<>"']/g, (c) => ({
267
+ "&": "&amp;",
268
+ "<": "&lt;",
269
+ ">": "&gt;",
270
+ "\"": "&quot;",
271
+ "'": "&#39;"
272
+ })[c]);
273
+ }
274
+ //#endregion
275
+ export { ThreepidService, canonicalAddress, isMedium };
@@ -0,0 +1,16 @@
1
+ import { Homeserver } from "../homeserver.js";
2
+ //#region src/core/typing.d.ts
3
+ /**
4
+ * Ephemeral typing notifications. Changes are replicated to every instance via the
5
+ * cluster adapter; each instance keeps the typing set in memory.
6
+ */
7
+ export declare class TypingService {
8
+ private hs;
9
+ private rooms;
10
+ constructor(hs: Homeserver);
11
+ setTyping(roomId: string, userId: string, typing: boolean, timeoutMs?: number): Promise<void>;
12
+ private apply;
13
+ /** Typing users in a room if they changed after `since` (or always when `since` is null). */
14
+ typingSince(roomId: string, since: number | null): string[] | null;
15
+ }
16
+ //#endregion
@@ -0,0 +1,60 @@
1
+ //#region src/core/typing.ts
2
+ /**
3
+ * Ephemeral typing notifications. Changes are replicated to every instance via the
4
+ * cluster adapter; each instance keeps the typing set in memory.
5
+ */
6
+ var TypingService = class {
7
+ hs;
8
+ rooms = /* @__PURE__ */ new Map();
9
+ constructor(hs) {
10
+ this.hs = hs;
11
+ hs.cluster.subscribe("typing", (msg) => this.apply(msg));
12
+ }
13
+ async setTyping(roomId, userId, typing, timeoutMs = 3e4) {
14
+ const wasTyping = !!this.rooms.get(roomId)?.users.has(userId);
15
+ if (!typing && !wasTyping) return;
16
+ const stream = await this.hs.streams.write(async (id) => id);
17
+ const msg = {
18
+ roomId,
19
+ userId,
20
+ typing,
21
+ timeoutMs: Math.min(Math.max(timeoutMs, 1e3), 12e4),
22
+ stream
23
+ };
24
+ this.apply(msg);
25
+ await this.hs.cluster.publish("typing", msg);
26
+ this.hs.notifier.notify(await this.hs.rooms.joinedMembers(roomId));
27
+ if (this.hs.isLocalUser(userId) && typing !== wasTyping || typing && this.hs.isLocalUser(userId)) await this.hs.federation.onTyping(roomId, userId, typing).catch(() => {});
28
+ }
29
+ apply(msg) {
30
+ let room = this.rooms.get(msg.roomId);
31
+ if (!room) this.rooms.set(msg.roomId, room = {
32
+ users: /* @__PURE__ */ new Map(),
33
+ stream: 0
34
+ });
35
+ const existing = room.users.get(msg.userId);
36
+ if (existing) clearTimeout(existing);
37
+ if (msg.typing) {
38
+ const timer = setTimeout(() => {
39
+ const r = this.rooms.get(msg.roomId);
40
+ if (r?.users.get(msg.userId) === timer) {
41
+ r.users.delete(msg.userId);
42
+ r.stream = Math.max(r.stream, msg.stream) + .5;
43
+ this.hs.rooms.joinedMembers(msg.roomId).then((m) => this.hs.notifier.notify(m)).catch(() => {});
44
+ }
45
+ }, msg.timeoutMs);
46
+ timer.unref?.();
47
+ room.users.set(msg.userId, timer);
48
+ } else room.users.delete(msg.userId);
49
+ room.stream = Math.max(room.stream, msg.stream);
50
+ }
51
+ /** Typing users in a room if they changed after `since` (or always when `since` is null). */
52
+ typingSince(roomId, since) {
53
+ const room = this.rooms.get(roomId);
54
+ if (!room) return since === null ? [] : null;
55
+ if (since !== null && room.stream <= since) return null;
56
+ return [...room.users.keys()];
57
+ }
58
+ };
59
+ //#endregion
60
+ export { TypingService };
@@ -0,0 +1,51 @@
1
+ import { Homeserver } from "../homeserver.js";
2
+ //#region src/federation/client.d.ts
3
+ interface Resolved {
4
+ /** Host to connect to (IP or hostname). */
5
+ host: string;
6
+ port: number;
7
+ /** Value for the Host header and TLS SNI/certificate name. */
8
+ hostHeader: string;
9
+ tlsName: string;
10
+ expires: number;
11
+ }
12
+ export interface FederationRequestOptions {
13
+ body?: unknown;
14
+ query?: Record<string, string | string[] | undefined>;
15
+ /** Sign the request with X-Matrix auth. Default: true. */
16
+ signed?: boolean;
17
+ timeoutMs?: number;
18
+ }
19
+ export interface RawResponse {
20
+ status: number;
21
+ headers: Record<string, string | string[] | undefined>;
22
+ body: Buffer;
23
+ }
24
+ /** Signs and sends server-server requests, implementing Matrix server discovery. */
25
+ export declare class FederationClient {
26
+ private hs;
27
+ private resolved;
28
+ /** One connection pool per destination, so a dead peer's sockets can be dropped without affecting others. */
29
+ private agents;
30
+ private rejectUnauthorized;
31
+ constructor(hs: Homeserver, opts?: {
32
+ rejectUnauthorized?: boolean;
33
+ });
34
+ private agentFor;
35
+ /** Drop pooled connections to a destination (after errors, the peer may have restarted). */
36
+ resetConnections(destination: string): void;
37
+ /** Build the X-Matrix Authorization header value for a request. */
38
+ authHeader(destination: string, method: string, uri: string, content?: unknown): string;
39
+ resolve(serverName: string): Promise<Resolved>;
40
+ private discover;
41
+ private wellKnown;
42
+ private srv;
43
+ private raw;
44
+ private pathWithQuery;
45
+ /** Send a request and return the raw response (used for media). */
46
+ requestRaw(destination: string, method: string, path: string, opts?: FederationRequestOptions): Promise<RawResponse>;
47
+ /** Send a request and parse the JSON response; non-2xx responses throw a MatrixError. */
48
+ request(destination: string, method: string, path: string, opts?: FederationRequestOptions): Promise<any>;
49
+ }
50
+ //#endregion
51
+ export {};
@@ -0,0 +1,278 @@
1
+ import { canonicalJson } from "../core/json.js";
2
+ import { unpaddedBase64 } from "../core/crypto.js";
3
+ import { MatrixError } from "../core/errors.js";
4
+ import { sign } from "node:crypto";
5
+ import { resolveSrv } from "node:dns/promises";
6
+ import { Agent, request } from "node:https";
7
+ import { isIP } from "node:net";
8
+ //#region src/federation/client.ts
9
+ function splitHostPort(name) {
10
+ const ipv6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(name);
11
+ if (ipv6) return {
12
+ host: ipv6[1],
13
+ port: ipv6[2] ? Number(ipv6[2]) : null
14
+ };
15
+ const idx = name.lastIndexOf(":");
16
+ if (idx > 0 && /^\d+$/.test(name.slice(idx + 1))) return {
17
+ host: name.slice(0, idx),
18
+ port: Number(name.slice(idx + 1))
19
+ };
20
+ return {
21
+ host: name,
22
+ port: null
23
+ };
24
+ }
25
+ /** Signs and sends server-server requests, implementing Matrix server discovery. */
26
+ var FederationClient = class {
27
+ hs;
28
+ resolved = /* @__PURE__ */ new Map();
29
+ /** One connection pool per destination, so a dead peer's sockets can be dropped without affecting others. */
30
+ agents = /* @__PURE__ */ new Map();
31
+ rejectUnauthorized;
32
+ constructor(hs, opts = {}) {
33
+ this.hs = hs;
34
+ this.rejectUnauthorized = opts.rejectUnauthorized ?? true;
35
+ }
36
+ agentFor(key) {
37
+ let agent = this.agents.get(key);
38
+ if (!agent) {
39
+ agent = new Agent({
40
+ keepAlive: true,
41
+ maxSockets: 16,
42
+ rejectUnauthorized: this.rejectUnauthorized
43
+ });
44
+ this.agents.set(key, agent);
45
+ }
46
+ return agent;
47
+ }
48
+ /** Drop pooled connections to a destination (after errors, the peer may have restarted). */
49
+ resetConnections(destination) {
50
+ const agent = this.agents.get(destination);
51
+ if (agent) {
52
+ agent.destroy();
53
+ this.agents.delete(destination);
54
+ }
55
+ this.resolved.delete(destination);
56
+ }
57
+ /** Build the X-Matrix Authorization header value for a request. */
58
+ authHeader(destination, method, uri, content) {
59
+ const key = this.hs.signingKey;
60
+ const toSign = {
61
+ method,
62
+ uri,
63
+ origin: this.hs.serverName,
64
+ destination
65
+ };
66
+ if (content !== void 0) toSign.content = content;
67
+ const sig = unpaddedBase64(sign(null, Buffer.from(canonicalJson(toSign)), key.privateKey));
68
+ return `X-Matrix origin="${this.hs.serverName}",destination="${destination}",key="ed25519:${key.keyId}",sig="${sig}"`;
69
+ }
70
+ async resolve(serverName) {
71
+ const cached = this.resolved.get(serverName);
72
+ if (cached && cached.expires > Date.now()) return cached;
73
+ const r = await this.discover(serverName);
74
+ this.resolved.set(serverName, r);
75
+ return r;
76
+ }
77
+ async discover(serverName) {
78
+ const day = Date.now() + 864e5;
79
+ const { host, port } = splitHostPort(serverName);
80
+ if (isIP(host)) return {
81
+ host,
82
+ port: port ?? 8448,
83
+ hostHeader: serverName,
84
+ tlsName: host,
85
+ expires: day
86
+ };
87
+ if (port !== null) return {
88
+ host,
89
+ port,
90
+ hostHeader: serverName,
91
+ tlsName: host,
92
+ expires: day
93
+ };
94
+ const delegated = await this.wellKnown(host);
95
+ if (delegated) {
96
+ const d = splitHostPort(delegated);
97
+ if (isIP(d.host)) return {
98
+ host: d.host,
99
+ port: d.port ?? 8448,
100
+ hostHeader: delegated,
101
+ tlsName: d.host,
102
+ expires: day
103
+ };
104
+ if (d.port !== null) return {
105
+ host: d.host,
106
+ port: d.port,
107
+ hostHeader: delegated,
108
+ tlsName: d.host,
109
+ expires: day
110
+ };
111
+ const srv = await this.srv(d.host);
112
+ if (srv) return {
113
+ ...srv,
114
+ hostHeader: d.host,
115
+ tlsName: d.host,
116
+ expires: day
117
+ };
118
+ return {
119
+ host: d.host,
120
+ port: 8448,
121
+ hostHeader: d.host,
122
+ tlsName: d.host,
123
+ expires: day
124
+ };
125
+ }
126
+ const srv = await this.srv(host);
127
+ if (srv) return {
128
+ ...srv,
129
+ hostHeader: host,
130
+ tlsName: host,
131
+ expires: Date.now() + 36e5
132
+ };
133
+ return {
134
+ host,
135
+ port: 8448,
136
+ hostHeader: host,
137
+ tlsName: host,
138
+ expires: Date.now() + 36e5
139
+ };
140
+ }
141
+ async wellKnown(host) {
142
+ try {
143
+ const res = await this.raw({
144
+ host,
145
+ port: 443,
146
+ hostHeader: host,
147
+ tlsName: host,
148
+ expires: 0
149
+ }, "GET", "/.well-known/matrix/server", void 0, {}, 5e3);
150
+ if (res.status !== 200) return null;
151
+ const body = JSON.parse(res.body.toString("utf8"));
152
+ return typeof body["m.server"] === "string" && body["m.server"] ? body["m.server"] : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ async srv(host) {
158
+ for (const name of [`_matrix-fed._tcp.${host}`, `_matrix._tcp.${host}`]) try {
159
+ const records = await resolveSrv(name);
160
+ if (records.length) {
161
+ records.sort((a, b) => a.priority - b.priority || b.weight - a.weight);
162
+ return {
163
+ host: records[0].name,
164
+ port: records[0].port
165
+ };
166
+ }
167
+ } catch {}
168
+ return null;
169
+ }
170
+ raw(target, method, pathWithQuery, body, headers, timeoutMs, poolKey = target.hostHeader) {
171
+ return new Promise((resolve, reject) => {
172
+ let connectTimer = null;
173
+ const req = request({
174
+ host: target.host,
175
+ port: target.port,
176
+ method,
177
+ path: pathWithQuery,
178
+ servername: isIP(target.tlsName) ? void 0 : target.tlsName,
179
+ agent: this.agentFor(poolKey),
180
+ headers: {
181
+ host: target.hostHeader,
182
+ ...headers,
183
+ ...body ? { "content-length": String(body.length) } : {}
184
+ },
185
+ timeout: timeoutMs
186
+ }, (res) => {
187
+ const chunks = [];
188
+ let size = 0;
189
+ res.on("data", (c) => {
190
+ size += c.length;
191
+ if (size > 104857600) {
192
+ req.destroy(/* @__PURE__ */ new Error("Response too large"));
193
+ return;
194
+ }
195
+ chunks.push(c);
196
+ });
197
+ res.on("end", () => resolve({
198
+ status: res.statusCode ?? 0,
199
+ headers: res.headers,
200
+ body: Buffer.concat(chunks)
201
+ }));
202
+ res.on("error", reject);
203
+ });
204
+ req.on("socket", (socket) => {
205
+ if (socket.connecting) {
206
+ connectTimer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error("Connection timed out")), Math.min(5e3, timeoutMs));
207
+ socket.once("connect", () => {
208
+ if (connectTimer) clearTimeout(connectTimer);
209
+ });
210
+ socket.once("secureConnect", () => {
211
+ if (connectTimer) clearTimeout(connectTimer);
212
+ });
213
+ }
214
+ });
215
+ req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error("Federation request timed out")));
216
+ req.on("error", (err) => {
217
+ if (connectTimer) clearTimeout(connectTimer);
218
+ reject(err);
219
+ });
220
+ req.on("close", () => {
221
+ if (connectTimer) clearTimeout(connectTimer);
222
+ });
223
+ if (body) req.write(body);
224
+ req.end();
225
+ });
226
+ }
227
+ pathWithQuery(path, query) {
228
+ const params = new URLSearchParams();
229
+ for (const [k, v] of Object.entries(query ?? {})) {
230
+ if (v === void 0) continue;
231
+ for (const item of Array.isArray(v) ? v : [v]) params.append(k, item);
232
+ }
233
+ const qs = params.toString();
234
+ return qs ? `${path}?${qs}` : path;
235
+ }
236
+ /** Send a request and return the raw response (used for media). */
237
+ async requestRaw(destination, method, path, opts = {}) {
238
+ if (destination === this.hs.serverName) throw new Error("Refusing to send a federation request to ourselves");
239
+ const uri = this.pathWithQuery(path, opts.query);
240
+ const body = opts.body === void 0 ? void 0 : Buffer.from(JSON.stringify(opts.body));
241
+ const headers = { accept: "application/json" };
242
+ if (body) headers["content-type"] = "application/json";
243
+ if (opts.signed !== false) headers.authorization = this.authHeader(destination, method, uri, opts.body);
244
+ const target = await this.resolve(destination);
245
+ try {
246
+ return await this.raw(target, method, uri, body, headers, opts.timeoutMs ?? 3e4, destination);
247
+ } catch (err) {
248
+ this.resetConnections(destination);
249
+ throw err;
250
+ }
251
+ }
252
+ /** Send a request and parse the JSON response; non-2xx responses throw a MatrixError. */
253
+ async request(destination, method, path, opts = {}) {
254
+ const res = await this.requestRaw(destination, method, path, opts);
255
+ let json = {};
256
+ try {
257
+ json = res.body.length ? JSON.parse(res.body.toString("utf8")) : {};
258
+ } catch {}
259
+ if (res.status < 200 || res.status >= 300) throw new MatrixError(res.status === 404 ? 404 : res.status >= 500 ? 502 : res.status, typeof json.errcode === "string" ? json.errcode : "M_UNKNOWN", typeof json.error === "string" ? json.error : `Remote server returned ${res.status}`);
260
+ return json;
261
+ }
262
+ };
263
+ /** Parse an `Authorization: X-Matrix ...` header. */
264
+ function parseXMatrix(header) {
265
+ const m = /^X-Matrix\s+(.*)$/i.exec(header.trim());
266
+ if (!m) return null;
267
+ const params = {};
268
+ for (const part of m[1].matchAll(/([A-Za-z]+)=(?:"((?:[^"\\]|\\.)*)"|([^,\s]*))/g)) params[part[1].toLowerCase()] = (part[2] ?? part[3] ?? "").replace(/\\(.)/g, "$1");
269
+ if (!params.origin || !params.key || !params.sig) return null;
270
+ return {
271
+ origin: params.origin,
272
+ destination: params.destination,
273
+ key: params.key,
274
+ sig: params.sig
275
+ };
276
+ }
277
+ //#endregion
278
+ export { FederationClient, parseXMatrix };