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.
@@ -131,14 +131,33 @@ function cacheFor(hs) {
131
131
  }
132
132
  return c;
133
133
  }
134
+ /** A user's push rules, as a copy the caller may change. */
134
135
  async function loadRules(hs, userId) {
136
+ return structuredClone(await readOnlyRules(hs, userId));
137
+ }
138
+ /**
139
+ * A user's push rules, shared and frozen: for callers that only read them.
140
+ *
141
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/41: push evaluation copied every
142
+ * member's rules for every message, about 6% of the CPU under load, though it only reads them.
143
+ * The cached rulesets are deep-frozen, so a caller that tries to change one fails loudly instead
144
+ * of changing everyone's.
145
+ */
146
+ async function readOnlyRules(hs, userId) {
135
147
  const cached = cacheFor(hs).get(userId);
136
- if (cached) return structuredClone(cached);
137
- const rules = await readRules(hs, userId);
148
+ if (cached) return cached;
149
+ const rules = deepFreeze(await readRules(hs, userId));
138
150
  const cache = cacheFor(hs);
139
151
  if (cache.size > 5e3) cache.clear();
140
152
  cache.set(userId, rules);
141
- return structuredClone(rules);
153
+ return rules;
154
+ }
155
+ function deepFreeze(value) {
156
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
157
+ Object.freeze(value);
158
+ for (const v of Object.values(value)) deepFreeze(v);
159
+ }
160
+ return value;
142
161
  }
143
162
  async function readRules(hs, userId) {
144
163
  const row = await hs.db.findOne("account_data", [
@@ -162,7 +181,7 @@ async function readRules(hs, userId) {
162
181
  return rules;
163
182
  }
164
183
  async function saveRules(hs, userId, rules) {
165
- cacheFor(hs).set(userId, structuredClone(rules));
184
+ cacheFor(hs).set(userId, deepFreeze(structuredClone(rules)));
166
185
  hs.cluster.publish("pushrules", {
167
186
  from: hs.instanceId,
168
187
  userId
@@ -226,4 +245,4 @@ async function copyRules(hs, userId, oldRoomId, newRoomId) {
226
245
  if (changed) await saveRules(hs, userId, rules);
227
246
  }
228
247
  //#endregion
229
- export { KINDS, copyRoomPushRules, defaultRules, loadRules, saveRules, withRulesLock };
248
+ export { KINDS, copyRoomPushRules, defaultRules, loadRules, readOnlyRules, saveRules, withRulesLock };
@@ -1,5 +1,5 @@
1
1
  //#region src/core/ratelimit.d.ts
2
- /** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances (e.g. Redis). */
2
+ /** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances. */
3
3
  export interface RateLimiter {
4
4
  /** Consume one unit for `key` under `rule`; resolve with the delay to wait (0 when allowed). */
5
5
  consume(key: string, rule: RateLimitRule): Promise<number>;
@@ -1,7 +1,7 @@
1
1
  import { ClientEvent, PDU } from "./events.js";
2
2
  import { StateMap } from "./authrules.js";
3
- import { StateIds } from "./stateres.js";
4
3
  import { Row } from "../storage/types.js";
4
+ import { StateIds } from "./stateres.js";
5
5
  import { Homeserver } from "../homeserver.js";
6
6
  //#region src/core/rooms.d.ts
7
7
  export interface StoredEvent {
@@ -140,6 +140,15 @@ export declare class RoomService {
140
140
  /** Users that should be woken up for events in this room. */
141
141
  private interestedUsers;
142
142
  roomsForUser(userId: string, membership?: string): Promise<string[]>;
143
+ /**
144
+ * Membership changes after `since` in the rooms where `userId` has a membership (in any state),
145
+ * given the user's own membership rows if the caller has them.
146
+ *
147
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/31: /sync and device-list tracking
148
+ * read every membership change on the server since their token and kept those in the user's
149
+ * rooms; while many users joined, each sync parsed thousands of rows. This reads those rooms only.
150
+ */
151
+ membershipChangesSince(userId: string, since: number, mine?: Row[]): Promise<Row[]>;
143
152
  /** All users sharing at least one joined room with `userId` (including themselves). */
144
153
  usersSharingRooms(userId: string): Promise<Set<string>>;
145
154
  private prepareRedaction;
@@ -1053,6 +1053,31 @@ var RoomService = class {
1053
1053
  value: membership
1054
1054
  }] })).map((r) => r.room_id);
1055
1055
  }
1056
+ /**
1057
+ * Membership changes after `since` in the rooms where `userId` has a membership (in any state),
1058
+ * given the user's own membership rows if the caller has them.
1059
+ *
1060
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/31: /sync and device-list tracking
1061
+ * read every membership change on the server since their token and kept those in the user's
1062
+ * rooms; while many users joined, each sync parsed thousands of rows. This reads those rooms only.
1063
+ */
1064
+ async membershipChangesSince(userId, since, mine) {
1065
+ const own = mine ?? await this.hs.db.findMany("memberships", { where: [{
1066
+ field: "user_id",
1067
+ value: userId
1068
+ }] });
1069
+ const rooms = [...new Set(own.map((m) => m.room_id))];
1070
+ if (rooms.length === 0) return [];
1071
+ return this.hs.db.findMany("memberships", { where: [{
1072
+ field: "room_id",
1073
+ operator: "in",
1074
+ value: rooms
1075
+ }, {
1076
+ field: "stream",
1077
+ operator: "gt",
1078
+ value: since
1079
+ }] });
1080
+ }
1056
1081
  /** All users sharing at least one joined room with `userId` (including themselves). */
1057
1082
  async usersSharingRooms(userId) {
1058
1083
  const cached = this.sharingCache.get(userId);
@@ -0,0 +1,23 @@
1
+ //#region src/core/semaphore.ts
2
+ /** Runs at most `limit` tasks at once; the others wait their turn, first come first served. */
3
+ var Semaphore = class {
4
+ limit;
5
+ active = 0;
6
+ waiting = [];
7
+ constructor(limit) {
8
+ this.limit = limit;
9
+ }
10
+ async run(fn) {
11
+ if (this.active >= this.limit) await new Promise((resolve) => this.waiting.push(resolve));
12
+ else this.active++;
13
+ try {
14
+ return await fn();
15
+ } finally {
16
+ const next = this.waiting.shift();
17
+ if (next) next();
18
+ else this.active--;
19
+ }
20
+ }
21
+ };
22
+ //#endregion
23
+ export { Semaphore };
@@ -28,11 +28,25 @@ export interface SsoProvider {
28
28
  }
29
29
  export interface SsoOptions {
30
30
  providers: SsoProvider[];
31
+ /**
32
+ * Clients that receive the login token straight away, as URL prefixes such as
33
+ * `"https://app.element.io/"` (same scheme and host, path starting with the prefix's path).
34
+ * Other clients get a page naming them, where the user confirms before the token is sent.
35
+ */
36
+ clientAllowlist?: string[];
37
+ }
38
+ /** Where the browser goes after login, and whether the user has to confirm that client first. */
39
+ export interface SsoResult {
40
+ url: string;
41
+ confirm: boolean;
42
+ /** The client as shown to the user: its host, or its scheme for app links. */
43
+ client: string;
31
44
  }
32
45
  /** Upstream OIDC login (authorization code + PKCE) issuing Matrix login tokens. */
33
46
  export declare class SsoService {
34
47
  private hs;
35
48
  readonly providers: SsoProvider[];
49
+ private clientAllowlist;
36
50
  private discovery;
37
51
  constructor(hs: Homeserver, options: SsoOptions);
38
52
  private callbackUrl;
@@ -40,7 +54,7 @@ export declare class SsoService {
40
54
  /** URL to send the user's browser to. */
41
55
  startLogin(providerId: string | null, redirectUrl: string): Promise<string>;
42
56
  /** Handle the provider's redirect; returns the client URL carrying a login token. */
43
- finishLogin(providerId: string, params: URLSearchParams): Promise<string>;
57
+ finishLogin(providerId: string, params: URLSearchParams): Promise<SsoResult>;
44
58
  private resolveUser;
45
59
  }
46
60
  //#endregion
package/dist/core/sso.js CHANGED
@@ -3,6 +3,34 @@ import { errors } from "./errors.js";
3
3
  import { LOCALPART_RE } from "./ids.js";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  //#region src/core/sso.ts
6
+ const UNSAFE_SCHEMES = /* @__PURE__ */ new Set([
7
+ "javascript:",
8
+ "data:",
9
+ "vbscript:",
10
+ "file:",
11
+ "blob:",
12
+ "about:"
13
+ ]);
14
+ /** The client's redirect URL, if it is one the server may send a login token to. */
15
+ function checkRedirectUrl(raw) {
16
+ if (!raw) return null;
17
+ try {
18
+ return UNSAFE_SCHEMES.has(new URL(raw).protocol) ? null : raw;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ function clientMatches(raw, entry) {
24
+ try {
25
+ const url = new URL(raw);
26
+ const allowed = new URL(entry);
27
+ if (allowed.host) return url.protocol === allowed.protocol && url.host === allowed.host && url.pathname.startsWith(allowed.pathname);
28
+ return raw.startsWith(entry);
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ const sameIssuer = (a, b) => typeof a === "string" && a.replace(/\/$/, "") === b.replace(/\/$/, "");
6
34
  function sanitizeLocalpart(raw) {
7
35
  return raw.toLowerCase().replace(/[^a-z0-9._=\-/+]/g, "_").slice(0, 200);
8
36
  }
@@ -15,10 +43,12 @@ function decodeJwtPayload(jwt) {
15
43
  var SsoService = class {
16
44
  hs;
17
45
  providers;
46
+ clientAllowlist;
18
47
  discovery = /* @__PURE__ */ new Map();
19
48
  constructor(hs, options) {
20
49
  this.hs = hs;
21
50
  this.providers = options.providers;
51
+ this.clientAllowlist = options.clientAllowlist ?? [];
22
52
  }
23
53
  callbackUrl(provider) {
24
54
  const base = this.hs.config.publicBaseUrl;
@@ -31,6 +61,7 @@ var SsoService = class {
31
61
  const res = await this.hs.fetch(`${provider.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(1e4) });
32
62
  if (!res.ok) throw new Error(`OIDC discovery for ${provider.id} failed with ${res.status}`);
33
63
  const meta = await res.json();
64
+ if (!sameIssuer(meta.issuer, provider.issuer)) throw new Error(`OIDC discovery for ${provider.id} returned another issuer`);
34
65
  this.discovery.set(provider.id, meta);
35
66
  return meta;
36
67
  }
@@ -96,6 +127,7 @@ var SsoService = class {
96
127
  if (!res.ok) throw errors.forbidden("Code exchange failed");
97
128
  const tokens = await res.json();
98
129
  let claims = {};
130
+ if ((provider.scopes ?? ["openid"]).includes("openid") && typeof tokens.id_token !== "string") throw errors.forbidden("The provider returned no ID token");
99
131
  if (typeof tokens.id_token === "string") {
100
132
  claims = decodeJwtPayload(tokens.id_token);
101
133
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
@@ -130,7 +162,11 @@ var SsoService = class {
130
162
  });
131
163
  const redirect = new URL(pending.redirectUrl);
132
164
  redirect.searchParams.set("loginToken", loginToken);
133
- return redirect.toString();
165
+ return {
166
+ url: redirect.toString(),
167
+ confirm: !this.clientAllowlist.some((entry) => clientMatches(pending.redirectUrl, entry)),
168
+ client: redirect.host || redirect.protocol.replace(/:$/, "")
169
+ };
134
170
  }
135
171
  async resolveUser(provider, claims) {
136
172
  const link = await this.hs.db.findOne("sso_identities", [{
@@ -162,4 +198,4 @@ var SsoService = class {
162
198
  }
163
199
  };
164
200
  //#endregion
165
- export { SsoService };
201
+ export { SsoService, checkRedirectUrl };
@@ -1,6 +1,7 @@
1
1
  import { randomString } from "./crypto.js";
2
2
  import { MatrixError, errors } from "./errors.js";
3
3
  import { toMsisdn } from "./phone.js";
4
+ import { escapeHtml } from "./html.js";
4
5
  import { randomInt } from "node:crypto";
5
6
  //#region src/core/threepid.ts
6
7
  const CLIENT_SECRET_RE = /^[0-9a-zA-Z.=_-]{1,255}$/;
@@ -262,14 +263,5 @@ var ThreepidService = class {
262
263
  };
263
264
  }
264
265
  };
265
- function escapeHtml(s) {
266
- return s.replace(/[&<>"']/g, (c) => ({
267
- "&": "&amp;",
268
- "<": "&lt;",
269
- ">": "&gt;",
270
- "\"": "&quot;",
271
- "'": "&#39;"
272
- })[c]);
273
- }
274
266
  //#endregion
275
267
  export { ThreepidService, canonicalAddress, isMedium };
@@ -2,6 +2,7 @@ import { canonicalJson } from "../core/json.js";
2
2
  import { unpaddedBase64 } from "../core/crypto.js";
3
3
  import { M } from "../core/m.js";
4
4
  import { MatrixError } from "../core/errors.js";
5
+ import { checkLiteral } from "../core/outbound.js";
5
6
  import { sign } from "node:crypto";
6
7
  import { resolveSrv } from "node:dns/promises";
7
8
  import { Agent, request } from "node:https";
@@ -169,6 +170,11 @@ var FederationClient = class {
169
170
  return null;
170
171
  }
171
172
  raw(target, method, pathWithQuery, body, headers, timeoutMs, poolKey = target.hostHeader) {
173
+ try {
174
+ checkLiteral(this.hs.addressPolicy, target.host);
175
+ } catch (err) {
176
+ return Promise.reject(err);
177
+ }
172
178
  return new Promise((resolve, reject) => {
173
179
  let connectTimer = null;
174
180
  const req = request({
@@ -178,6 +184,7 @@ var FederationClient = class {
178
184
  path: pathWithQuery,
179
185
  servername: isIP(target.tlsName) ? void 0 : target.tlsName,
180
186
  agent: this.agentFor(poolKey),
187
+ lookup: this.hs.addressPolicy.lookup,
181
188
  headers: {
182
189
  host: target.hostHeader,
183
190
  ...headers,
@@ -136,7 +136,7 @@ export declare class FederationService {
136
136
  */
137
137
  backfill(roomId: string, fromEventIds: string[], limit?: number): Promise<number>;
138
138
  /** Download remote media (authenticated federation endpoint, falling back to the legacy one). */
139
- fetchMedia(server: string, mediaId: string): Promise<{
139
+ fetchMedia(server: string, mediaId: string, maxBytes: number): Promise<{
140
140
  data: Uint8Array;
141
141
  contentType: string | null;
142
142
  filename: string | null;
@@ -8,6 +8,7 @@ import { authStateFromAuthEvents, checkAuth, getState, membershipOf } from "../c
8
8
  import { globMatch } from "../core/glob.js";
9
9
  import { resolveState } from "../core/stateres.js";
10
10
  import { MissingStateError } from "../core/rooms.js";
11
+ import { publicGet } from "../core/outbound.js";
11
12
  import { FederationClient } from "./client.js";
12
13
  import { KeyRing } from "./keyring.js";
13
14
  import { TransactionSender } from "./sender.js";
@@ -961,13 +962,16 @@ var FederationService = class {
961
962
  return 0;
962
963
  }
963
964
  /** Download remote media (authenticated federation endpoint, falling back to the legacy one). */
964
- async fetchMedia(server, mediaId) {
965
+ async fetchMedia(server, mediaId, maxBytes) {
965
966
  this.requireEnabled();
966
967
  const res = await this.client.requestRaw(server, "GET", `/_matrix/federation/v1/media/download/${encodeURIComponent(mediaId)}`, { timeoutMs: 6e4 });
967
968
  if (res.status === 200) {
968
969
  const ct = String(res.headers["content-type"] ?? "");
969
970
  const m = /boundary=("?)([^";]+)\1/.exec(ct);
970
- if (ct.startsWith("multipart/") && m) return parseMultipartMedia(res.body, m[2], this);
971
+ if (ct.startsWith("multipart/") && m) return parseMultipartMedia(res.body, m[2], {
972
+ maxBytes,
973
+ fetch: this.hs.publicFetch
974
+ });
971
975
  }
972
976
  const legacy = await this.client.requestRaw(server, "GET", `/_matrix/media/v3/download/${encodeURIComponent(server)}/${encodeURIComponent(mediaId)}`, {
973
977
  query: { allow_remote: "false" },
@@ -1020,7 +1024,7 @@ function filenameFromDisposition(header) {
1020
1024
  return plain ? plain[1] : null;
1021
1025
  }
1022
1026
  /** Parse a federation media multipart/mixed response: a JSON part then the file (or a redirect). */
1023
- async function parseMultipartMedia(body, boundary, fed) {
1027
+ async function parseMultipartMedia(body, boundary, limits) {
1024
1028
  const delimiter = Buffer.from(`--${boundary}`);
1025
1029
  const parts = [];
1026
1030
  let idx = body.indexOf(delimiter);
@@ -1041,10 +1045,13 @@ async function parseMultipartMedia(body, boundary, fed) {
1041
1045
  if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
1042
1046
  }
1043
1047
  if (headers.location) {
1044
- const res = await fetch(headers.location, { signal: AbortSignal.timeout(6e4) });
1045
- if (!res.ok) throw errors.notFound("Remote media redirect failed");
1048
+ const res = await publicGet(limits.fetch, headers.location, {
1049
+ maxBytes: limits.maxBytes,
1050
+ timeoutMs: 6e4
1051
+ });
1052
+ if (res.status !== 200) throw errors.notFound("Remote media redirect failed");
1046
1053
  return {
1047
- data: new Uint8Array(await res.arrayBuffer()),
1054
+ data: new Uint8Array(res.body),
1048
1055
  contentType: res.headers.get("content-type"),
1049
1056
  filename: filenameFromDisposition(res.headers.get("content-disposition") ?? void 0)
1050
1057
  };
@@ -6,10 +6,10 @@ import { IdentityServers } from "./core/identity.js";
6
6
  import { M } from "./core/m.js";
7
7
  import { AppServiceRegistration, AppServiceService } from "./core/appservices.js";
8
8
  import { AuthService } from "./core/auth.js";
9
+ import { DatabaseAdapter } from "./storage/types.js";
9
10
  import { KeyService } from "./core/keys.js";
10
11
  import { ClusterAdapter, StreamManager } from "./core/cluster.js";
11
12
  import { Notifier } from "./core/notifier.js";
12
- import { DatabaseAdapter } from "./storage/types.js";
13
13
  import { RoomService } from "./core/rooms.js";
14
14
  import { TypingService } from "./core/typing.js";
15
15
  import { PushService } from "./core/push.js";
@@ -26,6 +26,7 @@ import { SettingsService } from "./core/settings.js";
26
26
  import { AdminRoomOptions, AdminRoomService } from "./core/adminroom.js";
27
27
  import { ResolvedUserAuth, UserAuthAdapter } from "./adapters/auth.js";
28
28
  import { MediaAdapter, Thumbnailer } from "./adapters/media.js";
29
+ import { AddressPolicy } from "./core/outbound.js";
29
30
  import { EventEmitter } from "node:events";
30
31
  //#region src/homeserver.d.ts
31
32
  export interface Logger {
@@ -82,6 +83,11 @@ export interface HomeserverOptions {
82
83
  accessTokenLifetimeMs?: number;
83
84
  /** Maximum `/sync` long-poll duration in ms. Default: 30000. */
84
85
  maxSyncTimeoutMs?: number;
86
+ /**
87
+ * How many `/sync` responses one process builds at once (default 4). Requests woken together
88
+ * wait in the process instead of queueing their database reads ahead of everything else.
89
+ */
90
+ maxConcurrentSyncs?: number;
85
91
  /**
86
92
  * Enables `/preview_url`. Off by default because it makes the server fetch arbitrary URLs.
87
93
  * Private/internal addresses stay blocked unless `allowPrivateAddresses` is set.
@@ -92,6 +98,13 @@ export interface HomeserverOptions {
92
98
  logger?: Logger;
93
99
  /** Custom `fetch` for outgoing HTTP requests. */
94
100
  fetch?: typeof globalThis.fetch;
101
+ /**
102
+ * Let requests to addresses that users or remote servers choose (federation, push gateways,
103
+ * identity servers, URL previews, remote media) reach private addresses: localhost, LAN and
104
+ * other non-public ranges. `true` allows all of them, a list allows those ranges only (e.g.
105
+ * `["127.0.0.1/32"]` for a push gateway on the same machine). Default: none.
106
+ */
107
+ allowPrivateAddresses?: boolean | string[];
95
108
  /**
96
109
  * Rate limiting. `false` disables it; otherwise per-action overrides and an optional
97
110
  * shared limiter (defaults to an in-memory token bucket). Admins are never limited.
@@ -102,7 +115,7 @@ export interface HomeserverOptions {
102
115
  sso?: SsoOptions;
103
116
  /**
104
117
  * Coordination between instances. The default (`localCluster()`) supports one process;
105
- * use `postgresCluster()` / `redisCluster()` to scale horizontally.
118
+ * use `postgresCluster()` to scale horizontally.
106
119
  */
107
120
  cluster?: ClusterAdapter;
108
121
  /**
@@ -151,6 +164,7 @@ export interface ResolvedConfig {
151
164
  defaultRoomVersion: string;
152
165
  accessTokenLifetimeMs: number | null;
153
166
  maxSyncTimeoutMs: number;
167
+ maxConcurrentSyncs: number;
154
168
  urlPreviews: {
155
169
  allowPrivateAddresses: boolean;
156
170
  } | null;
@@ -189,8 +203,12 @@ export declare class Homeserver extends EventEmitter {
189
203
  readonly policyServers: PolicyServers;
190
204
  readonly threepids: ThreepidService;
191
205
  readonly identityServers: IdentityServers;
192
- /** `fetch` used for outgoing requests (push gateways, URL previews, OAuth). Overridable for testing. */
206
+ /** `fetch` for requests to addresses the operator configured (OAuth, SSO, application services). */
193
207
  readonly fetch: typeof globalThis.fetch;
208
+ /** Which addresses requests to user- or remote-chosen destinations may reach (see `allowPrivateAddresses`). */
209
+ readonly addressPolicy: AddressPolicy;
210
+ /** `fetch` for user- or remote-chosen URLs: refuses private addresses unless allowed, never follows redirects. */
211
+ readonly publicFetch: typeof globalThis.fetch;
194
212
  /** Resolves once storage is migrated and the server is ready to serve requests. */
195
213
  readonly ready: Promise<void>;
196
214
  private _signingKey;
@@ -3,6 +3,7 @@ import "./core/m.js";
3
3
  import { SUPPORTED_ROOM_VERSIONS } from "./core/events.js";
4
4
  import { PartialStateService } from "./core/partialstate.js";
5
5
  import { RoomService } from "./core/rooms.js";
6
+ import { addressPolicy, policyFetch } from "./core/outbound.js";
6
7
  import { FederationService } from "./federation/index.js";
7
8
  import { PolicyServers } from "./core/policy.js";
8
9
  import { ThreepidService } from "./core/threepid.js";
@@ -12,10 +13,11 @@ import { KeyService } from "./core/keys.js";
12
13
  import { Notifier } from "./core/notifier.js";
13
14
  import { StreamManager, localCluster, newInstanceId } from "./core/cluster.js";
14
15
  import { TypingService } from "./core/typing.js";
16
+ import { coalesceReads } from "./storage/batch.js";
15
17
  import { PushService } from "./core/push.js";
18
+ import { SsoService } from "./core/sso.js";
16
19
  import { AdminService } from "./core/admin.js";
17
20
  import { OAuthDelegation } from "./core/oauth.js";
18
- import { SsoService } from "./core/sso.js";
19
21
  import { DelayedEventService } from "./core/delayed.js";
20
22
  import { AuditService } from "./core/audit.js";
21
23
  import { SettingsService } from "./core/settings.js";
@@ -78,8 +80,12 @@ var Homeserver = class extends EventEmitter {
78
80
  policyServers;
79
81
  threepids;
80
82
  identityServers;
81
- /** `fetch` used for outgoing requests (push gateways, URL previews, OAuth). Overridable for testing. */
83
+ /** `fetch` for requests to addresses the operator configured (OAuth, SSO, application services). */
82
84
  fetch;
85
+ /** Which addresses requests to user- or remote-chosen destinations may reach (see `allowPrivateAddresses`). */
86
+ addressPolicy;
87
+ /** `fetch` for user- or remote-chosen URLs: refuses private addresses unless allowed, never follows redirects. */
88
+ publicFetch;
83
89
  /** Resolves once storage is migrated and the server is ready to serve requests. */
84
90
  ready;
85
91
  _signingKey = null;
@@ -109,6 +115,7 @@ var Homeserver = class extends EventEmitter {
109
115
  defaultRoomVersion,
110
116
  accessTokenLifetimeMs: options.accessTokenLifetimeMs ?? null,
111
117
  maxSyncTimeoutMs: options.maxSyncTimeoutMs ?? 3e4,
118
+ maxConcurrentSyncs: Math.max(1, options.maxConcurrentSyncs ?? 4),
112
119
  urlPreviews: options.urlPreviews ? { allowPrivateAddresses: typeof options.urlPreviews === "object" && !!options.urlPreviews.allowPrivateAddresses } : null,
113
120
  thumbnailer: options.thumbnailer ?? null,
114
121
  rtc: options.rtc ?? {},
@@ -119,7 +126,7 @@ var Homeserver = class extends EventEmitter {
119
126
  }
120
127
  };
121
128
  const db = options.databaseAdapter;
122
- if (db && typeof db.findOne === "function") this.db = db;
129
+ if (db && typeof db.findOne === "function") this.db = coalesceReads(db);
123
130
  else {
124
131
  this.logger.warn("No database adapter given; using in-memory storage (data is lost on restart)");
125
132
  this.db = memoryAdapter();
@@ -138,6 +145,9 @@ var Homeserver = class extends EventEmitter {
138
145
  this.push = new PushService(this);
139
146
  this.admin = new AdminService(this);
140
147
  this.oauth = options.oauth ? new OAuthDelegation(this, options.oauth) : null;
148
+ this.fetch = options.fetch ?? globalThis.fetch;
149
+ this.addressPolicy = addressPolicy(options.allowPrivateAddresses);
150
+ this.publicFetch = policyFetch(this.addressPolicy, options.fetch);
141
151
  this.federation = new FederationService(this, options.federation);
142
152
  this.delayedEvents = new DelayedEventService(this, options.delayedEvents);
143
153
  this.audit = new AuditService(this, options.audit);
@@ -157,7 +167,6 @@ var Homeserver = class extends EventEmitter {
157
167
  this.cleanupTimer = setInterval(() => void this.cleanup().catch((err) => this.logger.warn("Cleanup failed", err)), cleanupEvery);
158
168
  this.cleanupTimer.unref?.();
159
169
  }
160
- this.fetch = options.fetch ?? globalThis.fetch;
161
170
  registerRoutes(this.router);
162
171
  this.ready = this.init(options);
163
172
  this.ready.catch((err) => this.logger.error("Homeserver failed to start", err));
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ThreepidEmail, ThreepidOptions, ThreepidPurpose, ThreepidSms } from "./core/threepid.js";
2
2
  import { M, UNSTABLE } from "./core/m.js";
3
3
  import { AppServiceRegistration, loadAppServiceRegistrations, parseAppServiceRegistration } from "./core/appservices.js";
4
- import { ClusterAdapter, localCluster } from "./core/cluster.js";
5
4
  import { DatabaseAdapter, FieldDef, FindManyOptions, ModelDef, Operator, Row, Schema, WhereClause } from "./storage/types.js";
5
+ import { ClusterAdapter, localCluster } from "./core/cluster.js";
6
6
  import { AdminService, ServerNoticesOptions } from "./core/admin.js";
7
7
  import { OAuthDelegationOptions } from "./core/oauth.js";
8
8
  import { SsoOptions, SsoProvider } from "./core/sso.js";
@@ -22,5 +22,5 @@ import { memoryAdapter } from "./storage/memory.js";
22
22
  import { MysqlPoolLike, PgPoolLike, SqlAdapterOptions, SqlDialect, SqlExecutor, SqliteDatabaseLike, mysqlAdapter, postgresAdapter, sqlAdapter, sqliteAdapter } from "./storage/sql.js";
23
23
  import { schema } from "./storage/schema.js";
24
24
  import { S3MediaOptions, s3Media } from "./adapters/s3.js";
25
- import { PgClusterPoolLike, RedisLike, RedisSubscriberLike, postgresCluster, redisCluster } from "./adapters/cluster.js";
26
- export { type AdminRoomOptions, type AdminService, type AppServiceRegistration, type AuditAction, type AuditEntry, type AuditOptions, type ClusterAdapter, type DatabaseAdapter, type DelayedEventsOptions, type FederationOptions, type FieldDef, type FindManyOptions, Homeserver, type HomeserverOptions, type Logger, M, MatrixError, type MediaAdapter, type MediaMetadata, type ModelDef, type MysqlPoolLike, type NodeHandler, type OAuthDelegationOptions, type Operator, type PasswordCheckContext, type PgClusterPoolLike, type PgPoolLike, type RateLimitAction, type RateLimitRule, type RateLimiter, type RedisLike, type RedisSubscriberLike, type Row, type RtcOptions, type S3MediaOptions, type Schema, type ServerNoticesOptions, type SqlAdapterOptions, type SqlDialect, type SqlExecutor, type SqliteDatabaseLike, type SsoOptions, type SsoProvider, type ThreepidEmail, type ThreepidOptions, type ThreepidPurpose, type ThreepidSms, type ThumbnailRequest, type Thumbnailer, type TurnOptions, UNSTABLE, type UserAuthAdapter, type WhereClause, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster, redisCluster, s3Media, schema, sharpThumbnailer, sqlAdapter, sqliteAdapter };
25
+ import { PgClusterPoolLike, postgresCluster } from "./adapters/cluster.js";
26
+ export { type AdminRoomOptions, type AdminService, type AppServiceRegistration, type AuditAction, type AuditEntry, type AuditOptions, type ClusterAdapter, type DatabaseAdapter, type DelayedEventsOptions, type FederationOptions, type FieldDef, type FindManyOptions, Homeserver, type HomeserverOptions, type Logger, M, MatrixError, type MediaAdapter, type MediaMetadata, type ModelDef, type MysqlPoolLike, type NodeHandler, type OAuthDelegationOptions, type Operator, type PasswordCheckContext, type PgClusterPoolLike, type PgPoolLike, type RateLimitAction, type RateLimitRule, type RateLimiter, type Row, type RtcOptions, type S3MediaOptions, type Schema, type ServerNoticesOptions, type SqlAdapterOptions, type SqlDialect, type SqlExecutor, type SqliteDatabaseLike, type SsoOptions, type SsoProvider, type ThreepidEmail, type ThreepidOptions, type ThreepidPurpose, type ThreepidSms, type ThumbnailRequest, type Thumbnailer, type TurnOptions, UNSTABLE, type UserAuthAdapter, type WhereClause, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster, s3Media, schema, sharpThumbnailer, sqlAdapter, sqliteAdapter };
package/dist/index.js CHANGED
@@ -11,5 +11,5 @@ import { Homeserver, createHomeserver } from "./homeserver.js";
11
11
  import { getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, isMatrixPath } from "./http/adapters.js";
12
12
  import { mysqlAdapter, postgresAdapter, sqlAdapter, sqliteAdapter } from "./storage/sql.js";
13
13
  import { s3Media } from "./adapters/s3.js";
14
- import { postgresCluster, redisCluster } from "./adapters/cluster.js";
15
- export { Homeserver, M, MatrixError, UNSTABLE, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster, redisCluster, s3Media, schema, sharpThumbnailer, sqlAdapter, sqliteAdapter };
14
+ import { postgresCluster } from "./adapters/cluster.js";
15
+ export { Homeserver, M, MatrixError, UNSTABLE, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster, s3Media, schema, sharpThumbnailer, sqlAdapter, sqliteAdapter };
@@ -0,0 +1,82 @@
1
+ //#region src/storage/batch.ts
2
+ /**
3
+ * Run independent reads together: in one round trip when the adapter can (`findManyBatch`),
4
+ * otherwise concurrently. Results come in the order of the queries.
5
+ */
6
+ function readBatch(db, queries) {
7
+ if (db.findManyBatch) return db.findManyBatch(queries);
8
+ return Promise.all(queries.map((query) => db.findMany(query.model, query.opts)));
9
+ }
10
+ /** Upsert several rows: in a few statements when the adapter can (`upsertMany`), otherwise one by one. */
11
+ async function upsertMany(db, model, rows) {
12
+ if (rows.length === 0) return;
13
+ if (db.upsertMany) return db.upsertMany(model, rows);
14
+ for (const row of rows) await db.upsert(model, row);
15
+ }
16
+ /** At most this many reads go into one coalesced statement. */
17
+ const MAX_COALESCED = 32;
18
+ /**
19
+ * An adapter whose reads (`findMany`, `findOne`) issued within one turn of the event loop are
20
+ * sent together, through `findManyBatch`. Writes and transactions go straight to `db`. Adapters
21
+ * without `findManyBatch` are returned as they are.
22
+ *
23
+ * https://github.com/k18k/matrix-nodejs-homeserver/issues/43: under load, most of the homeserver's
24
+ * and the database's CPU went to the per-statement cost (a socket write, a round trip, parsing)
25
+ * of many small reads issued at the same moment by concurrent requests. Nothing is kept between
26
+ * turns: this merges requests, it does not cache results. A read waits for at most the rest of
27
+ * the current turn. If a coalesced statement fails, its reads are run one by one, so a bad read
28
+ * fails alone.
29
+ */
30
+ function coalesceReads(db) {
31
+ if (!db.findManyBatch) return db;
32
+ let queue = [];
33
+ const runAlone = (read) => db.findMany(read.query.model, read.query.opts).then(read.resolve, read.reject);
34
+ const run = async (reads) => {
35
+ if (reads.length === 1) return runAlone(reads[0]);
36
+ let results;
37
+ try {
38
+ results = await db.findManyBatch(reads.map((r) => r.query), { prepare: false });
39
+ } catch {
40
+ for (const read of reads) runAlone(read);
41
+ return;
42
+ }
43
+ reads.forEach((read, i) => read.resolve(results[i]));
44
+ };
45
+ const flush = () => {
46
+ const reads = queue;
47
+ queue = [];
48
+ for (let i = 0; i < reads.length; i += MAX_COALESCED) run(reads.slice(i, i + MAX_COALESCED));
49
+ };
50
+ const enqueue = (query) => new Promise((resolve, reject) => {
51
+ if (queue.length === 0) setImmediate(flush);
52
+ queue.push({
53
+ query,
54
+ resolve,
55
+ reject
56
+ });
57
+ });
58
+ return {
59
+ findMany: (model, opts) => enqueue({
60
+ model,
61
+ opts
62
+ }),
63
+ findOne: async (model, where) => (await enqueue({
64
+ model,
65
+ opts: {
66
+ where,
67
+ limit: 1
68
+ }
69
+ }))[0] ?? null,
70
+ findManyBatch: (queries, options) => db.findManyBatch(queries, options),
71
+ create: (model, data) => db.create(model, data),
72
+ upsert: (model, data) => db.upsert(model, data),
73
+ ...db.upsertMany ? { upsertMany: (model, rows) => db.upsertMany(model, rows) } : {},
74
+ update: (model, where, data) => db.update(model, where, data),
75
+ delete: (model, where) => db.delete(model, where),
76
+ max: (model, field) => db.max(model, field),
77
+ ...db.migrate ? { migrate: (schema) => db.migrate(schema) } : {},
78
+ ...db.transaction ? { transaction: (fn) => db.transaction(fn) } : {}
79
+ };
80
+ }
81
+ //#endregion
82
+ export { coalesceReads, readBatch, upsertMany };
@@ -72,6 +72,7 @@ function memoryAdapter() {
72
72
  rows.sort((a, b) => a[field] < b[field] ? -dir : a[field] > b[field] ? dir : 0);
73
73
  }
74
74
  if (opts.limit !== void 0) rows = rows.slice(0, opts.limit);
75
+ if (opts.fields?.length) return rows.map((r) => Object.fromEntries(opts.fields.map((f) => [f, structuredClone(r[f] ?? null)])));
75
76
  return rows.map((r) => structuredClone(r));
76
77
  },
77
78
  async update(model, where, data) {