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/dist/core/pushrules.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
package/dist/core/ratelimit.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/core/rooms.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/rooms.js
CHANGED
|
@@ -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 };
|
package/dist/core/sso.d.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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 };
|
package/dist/core/threepid.js
CHANGED
|
@@ -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
|
-
"&": "&",
|
|
268
|
-
"<": "<",
|
|
269
|
-
">": ">",
|
|
270
|
-
"\"": """,
|
|
271
|
-
"'": "'"
|
|
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;
|
package/dist/federation/index.js
CHANGED
|
@@ -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],
|
|
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,
|
|
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
|
|
1045
|
-
|
|
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(
|
|
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
|
};
|
package/dist/homeserver.d.ts
CHANGED
|
@@ -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()`
|
|
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`
|
|
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;
|
package/dist/homeserver.js
CHANGED
|
@@ -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`
|
|
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,
|
|
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
|
|
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
|
|
15
|
-
export { Homeserver, M, MatrixError, UNSTABLE, createHomeserver, filesystemMedia, getExpressHandler, getFastifyPlugin, getFetchHandler, getHonoHandler, getNodeHandler, handleRequest, isMatrixPath, loadAppServiceRegistrations, localCluster, memoryAdapter, memoryMedia, memoryRateLimiter, mysqlAdapter, parseAppServiceRegistration, postgresAdapter, postgresCluster,
|
|
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 };
|
package/dist/storage/memory.js
CHANGED
|
@@ -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) {
|