matrix-nodejs-homeserver 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -5
- 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/sync.js +268 -87
- 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 +29 -17
- 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 +11 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/storage/batch.js +11 -0
- package/dist/storage/memory.js +1 -0
- package/dist/storage/schema.js +1 -1
- package/dist/storage/sql.js +20 -1
- package/dist/storage/types.d.ts +16 -0
- package/package.json +6 -2
package/dist/core/json.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
//#region src/core/json.ts
|
|
2
|
+
function byCodePoint(a, b) {
|
|
3
|
+
for (let i = 0; i < a.length && i < b.length;) {
|
|
4
|
+
const ca = a.codePointAt(i);
|
|
5
|
+
const cb = b.codePointAt(i);
|
|
6
|
+
if (ca !== cb) return ca - cb;
|
|
7
|
+
i += ca > 65535 ? 2 : 1;
|
|
8
|
+
}
|
|
9
|
+
return a.length - b.length;
|
|
10
|
+
}
|
|
2
11
|
/** Matrix canonical JSON: sorted keys, no whitespace, UTF-8, integers only. */
|
|
3
12
|
function canonicalJson(value) {
|
|
4
13
|
if (value === null || typeof value !== "object") {
|
|
@@ -7,7 +16,7 @@ function canonicalJson(value) {
|
|
|
7
16
|
}
|
|
8
17
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
9
18
|
const obj = value;
|
|
10
|
-
return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
19
|
+
return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort(byCodePoint).map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
11
20
|
}
|
|
12
21
|
function isObject(value) {
|
|
13
22
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/dist/core/keys.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Row } from "../storage/types.js";
|
|
1
2
|
import { Homeserver } from "../homeserver.js";
|
|
2
3
|
//#region src/core/keys.d.ts
|
|
3
4
|
/** End-to-end encryption key storage (device keys, one-time keys, cross-signing). */
|
|
@@ -28,7 +29,15 @@ export declare class KeyService {
|
|
|
28
29
|
uploadCrossSigning(userId: string, body: Record<string, any>): Promise<void>;
|
|
29
30
|
uploadSignatures(signer: string, body: Record<string, any>): Promise<Record<string, any>>;
|
|
30
31
|
/** Users (sharing a room with `userId`) whose device lists changed after `since`. */
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Device-list changes for /sync and /keys/changes. /sync passes what it has already read (the
|
|
34
|
+
* membership changes, the device-list rows, the user's joined rooms) to save the queries.
|
|
35
|
+
*/
|
|
36
|
+
changesSince(userId: string, since: number, upTo: number, known?: {
|
|
37
|
+
memberChanges?: Row[];
|
|
38
|
+
deviceRows?: Row[];
|
|
39
|
+
joinedRooms?: string[];
|
|
40
|
+
}): Promise<{
|
|
32
41
|
changed: string[];
|
|
33
42
|
left: string[];
|
|
34
43
|
}>;
|
package/dist/core/keys.js
CHANGED
|
@@ -99,39 +99,45 @@ var KeyService = class {
|
|
|
99
99
|
return this.oneTimeKeyCounts(userId, deviceId);
|
|
100
100
|
}
|
|
101
101
|
async oneTimeKeyCounts(userId, deviceId) {
|
|
102
|
-
const rows = await this.hs.db.findMany("one_time_keys", {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
const rows = await this.hs.db.findMany("one_time_keys", {
|
|
103
|
+
where: [
|
|
104
|
+
{
|
|
105
|
+
field: "user_id",
|
|
106
|
+
value: userId
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
field: "device_id",
|
|
110
|
+
value: deviceId
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
field: "fallback",
|
|
114
|
+
value: false
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
fields: ["algorithm"]
|
|
118
|
+
});
|
|
116
119
|
const counts = { signed_curve25519: 0 };
|
|
117
120
|
for (const r of rows) counts[r.algorithm] = (counts[r.algorithm] ?? 0) + 1;
|
|
118
121
|
return counts;
|
|
119
122
|
}
|
|
120
123
|
async unusedFallbackKeyTypes(userId, deviceId) {
|
|
121
|
-
const rows = await this.hs.db.findMany("fallback_keys", {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
124
|
+
const rows = await this.hs.db.findMany("fallback_keys", {
|
|
125
|
+
where: [
|
|
126
|
+
{
|
|
127
|
+
field: "user_id",
|
|
128
|
+
value: userId
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
field: "device_id",
|
|
132
|
+
value: deviceId
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
field: "used",
|
|
136
|
+
value: false
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
fields: ["algorithm"]
|
|
140
|
+
});
|
|
135
141
|
return [...new Set(rows.map((r) => r.algorithm))];
|
|
136
142
|
}
|
|
137
143
|
async query(requester, deviceKeys, timeoutMs = 1e4) {
|
|
@@ -413,22 +419,18 @@ var KeyService = class {
|
|
|
413
419
|
return { failures };
|
|
414
420
|
}
|
|
415
421
|
/** Users (sharing a room with `userId`) whose device lists changed after `since`. */
|
|
416
|
-
|
|
422
|
+
/**
|
|
423
|
+
* Device-list changes for /sync and /keys/changes. /sync passes what it has already read (the
|
|
424
|
+
* membership changes, the device-list rows, the user's joined rooms) to save the queries.
|
|
425
|
+
*/
|
|
426
|
+
async changesSince(userId, since, upTo, known = {}) {
|
|
417
427
|
const sharing = await this.hs.rooms.usersSharingRooms(userId);
|
|
418
|
-
const rows = await this.hs.db.findMany("device_list_changes",
|
|
419
|
-
field: "stream",
|
|
420
|
-
operator: "gt",
|
|
421
|
-
value: since
|
|
422
|
-
}] });
|
|
428
|
+
const rows = known.deviceRows ?? await this.hs.db.findMany("device_list_changes", deviceListChangesQuery(sharing, since));
|
|
423
429
|
const changed = /* @__PURE__ */ new Set();
|
|
424
430
|
for (const r of rows) if (r.stream <= upTo && sharing.has(r.user_id)) changed.add(r.user_id);
|
|
425
431
|
const left = /* @__PURE__ */ new Set();
|
|
426
|
-
const memberRows = await this.hs.
|
|
427
|
-
|
|
428
|
-
operator: "gt",
|
|
429
|
-
value: since
|
|
430
|
-
}] });
|
|
431
|
-
const myRooms = /* @__PURE__ */ new Set([...await this.hs.rooms.roomsForUser(userId), ...memberRows.filter((m) => m.user_id === userId).map((m) => m.room_id)]);
|
|
432
|
+
const memberRows = known.memberChanges ?? await this.hs.rooms.membershipChangesSince(userId, since);
|
|
433
|
+
const myRooms = /* @__PURE__ */ new Set([...known.joinedRooms ?? await this.hs.rooms.roomsForUser(userId), ...memberRows.filter((m) => m.user_id === userId).map((m) => m.room_id)]);
|
|
432
434
|
for (const m of memberRows) {
|
|
433
435
|
if (m.stream > upTo || !myRooms.has(m.room_id)) continue;
|
|
434
436
|
if (m.user_id === userId) {
|
|
@@ -446,5 +448,22 @@ var KeyService = class {
|
|
|
446
448
|
};
|
|
447
449
|
}
|
|
448
450
|
};
|
|
451
|
+
/**
|
|
452
|
+
* The device-list changes after `since` of the users sharing a room with someone.
|
|
453
|
+
* https://github.com/k18k/matrix-nodejs-homeserver/issues/31: this read every device-list change
|
|
454
|
+
* on the server since `since`; it asks for the users sharing a room only (up to a size where an IN
|
|
455
|
+
* list stays reasonable for the database).
|
|
456
|
+
*/
|
|
457
|
+
function deviceListChangesQuery(sharing, since) {
|
|
458
|
+
return { where: [{
|
|
459
|
+
field: "stream",
|
|
460
|
+
operator: "gt",
|
|
461
|
+
value: since
|
|
462
|
+
}, ...sharing.size <= 5e3 ? [{
|
|
463
|
+
field: "user_id",
|
|
464
|
+
operator: "in",
|
|
465
|
+
value: [...sharing]
|
|
466
|
+
}] : []] };
|
|
467
|
+
}
|
|
449
468
|
//#endregion
|
|
450
|
-
export { KeyService };
|
|
469
|
+
export { KeyService, deviceListChangesQuery };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LookupAddress, LookupOptions } from "node:dns";
|
|
2
|
+
//#region src/core/outbound.d.ts
|
|
3
|
+
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void;
|
|
4
|
+
/** Which addresses outbound requests to user- or remote-chosen destinations may connect to. */
|
|
5
|
+
export interface AddressPolicy {
|
|
6
|
+
allows(address: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* A `dns.lookup` for http(s) requests that refuses disallowed addresses. It runs when the
|
|
9
|
+
* connection is made, so the address checked is the one connected to: a name cannot resolve to
|
|
10
|
+
* a public address for a check and to a private one for the request (DNS rebinding).
|
|
11
|
+
* Undefined when every address is allowed.
|
|
12
|
+
*/
|
|
13
|
+
lookup?: (hostname: string, options: LookupOptions, callback: LookupCallback) => void;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { errors } from "./errors.js";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { request } from "node:https";
|
|
4
|
+
import { BlockList, isIP } from "node:net";
|
|
5
|
+
import { lookup as lookup$1 } from "node:dns";
|
|
6
|
+
import { request as request$1 } from "node:http";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
//#region src/core/outbound.ts
|
|
9
|
+
const privateRanges = new BlockList();
|
|
10
|
+
for (const [net, prefix] of [
|
|
11
|
+
["0.0.0.0", 8],
|
|
12
|
+
["10.0.0.0", 8],
|
|
13
|
+
["100.64.0.0", 10],
|
|
14
|
+
["127.0.0.0", 8],
|
|
15
|
+
["169.254.0.0", 16],
|
|
16
|
+
["172.16.0.0", 12],
|
|
17
|
+
["192.0.0.0", 24],
|
|
18
|
+
["192.168.0.0", 16],
|
|
19
|
+
["198.18.0.0", 15],
|
|
20
|
+
["224.0.0.0", 4],
|
|
21
|
+
["240.0.0.0", 4]
|
|
22
|
+
]) privateRanges.addSubnet(net, prefix, "ipv4");
|
|
23
|
+
for (const [net, prefix] of [
|
|
24
|
+
["::", 128],
|
|
25
|
+
["::1", 128],
|
|
26
|
+
["fc00::", 7],
|
|
27
|
+
["fe80::", 10],
|
|
28
|
+
["ff00::", 8]
|
|
29
|
+
]) privateRanges.addSubnet(net, prefix, "ipv6");
|
|
30
|
+
/** Whether an IP address is loopback, private, link-local, multicast or otherwise not public. */
|
|
31
|
+
function isPrivateAddress(address) {
|
|
32
|
+
const family = isIP(address);
|
|
33
|
+
return family !== 0 && privateRanges.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
34
|
+
}
|
|
35
|
+
/** The policy for the `allowPrivateAddresses` option. */
|
|
36
|
+
function addressPolicy(allowPrivate) {
|
|
37
|
+
if (allowPrivate === true) return { allows: () => true };
|
|
38
|
+
const allowed = new BlockList();
|
|
39
|
+
for (const entry of Array.isArray(allowPrivate) ? allowPrivate : []) {
|
|
40
|
+
const [address, prefix] = entry.split("/");
|
|
41
|
+
const family = isIP(address);
|
|
42
|
+
if (family === 0) throw new Error(`allowPrivateAddresses: not an IP address or range: ${entry}`);
|
|
43
|
+
allowed.addSubnet(address, prefix === void 0 ? family === 6 ? 128 : 32 : Number(prefix), family === 6 ? "ipv6" : "ipv4");
|
|
44
|
+
}
|
|
45
|
+
const allows = (address) => !isPrivateAddress(address) || allowed.check(address, isIP(address) === 6 ? "ipv6" : "ipv4");
|
|
46
|
+
return {
|
|
47
|
+
allows,
|
|
48
|
+
lookup: (hostname, options, callback) => {
|
|
49
|
+
lookup$1(hostname, {
|
|
50
|
+
...options,
|
|
51
|
+
all: true
|
|
52
|
+
}, (err, addresses) => {
|
|
53
|
+
if (err) return callback(err, []);
|
|
54
|
+
if (addresses.length === 0 || !addresses.every((a) => allows(a.address))) return callback(privateAddressError(hostname), []);
|
|
55
|
+
if (options.all) callback(null, addresses);
|
|
56
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function privateAddressError(host) {
|
|
62
|
+
return Object.assign(/* @__PURE__ */ new Error(`${host} resolves to a private address`), { code: "EPRIVATE" });
|
|
63
|
+
}
|
|
64
|
+
/** Refuse an IP literal the policy does not allow (literals are connected to without a lookup). */
|
|
65
|
+
function checkLiteral(policy, host) {
|
|
66
|
+
const literal = host.replace(/^\[|\]$/g, "");
|
|
67
|
+
if (isIP(literal) && !policy.allows(literal)) throw errors.forbidden("Address is private");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A `fetch` for user- or remote-chosen URLs that connects only to addresses `policy` allows.
|
|
71
|
+
* Redirects are never followed: callers that follow them send the next request through this
|
|
72
|
+
* function again, so each target is checked. Only http(s) URLs are accepted.
|
|
73
|
+
*
|
|
74
|
+
* With a custom `fetch` (the `fetch` option, e.g. for a proxy), the host is resolved and checked
|
|
75
|
+
* before the request instead. That leaves the DNS-rebinding window open; the custom client is the
|
|
76
|
+
* operator's to secure.
|
|
77
|
+
*/
|
|
78
|
+
function policyFetch(policy, custom) {
|
|
79
|
+
return async (input, init = {}) => {
|
|
80
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
81
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw errors.forbidden("Only http and https URLs can be fetched");
|
|
82
|
+
checkLiteral(policy, url.hostname);
|
|
83
|
+
if (custom) {
|
|
84
|
+
if (policy.lookup && !isIP(url.hostname.replace(/^\[|\]$/g, ""))) {
|
|
85
|
+
if (!(await lookup(url.hostname, { all: true })).every((a) => policy.allows(a.address))) throw errors.forbidden("Address is private");
|
|
86
|
+
}
|
|
87
|
+
return custom(input, {
|
|
88
|
+
...init,
|
|
89
|
+
redirect: "manual"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return nodeFetch(url, init, policy);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const NULL_BODY = /* @__PURE__ */ new Set([
|
|
96
|
+
101,
|
|
97
|
+
103,
|
|
98
|
+
204,
|
|
99
|
+
205,
|
|
100
|
+
304
|
|
101
|
+
]);
|
|
102
|
+
function nodeFetch(url, init, policy) {
|
|
103
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
104
|
+
const headers = new Headers(init.headers);
|
|
105
|
+
const body = init.body === void 0 || init.body === null ? void 0 : typeof init.body === "string" || init.body instanceof URLSearchParams ? Buffer.from(String(init.body)) : Buffer.from(init.body);
|
|
106
|
+
if (body && init.body instanceof URLSearchParams && !headers.has("content-type")) headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
107
|
+
const request$2 = url.protocol === "https:" ? request : request$1;
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const req = request$2(url, {
|
|
110
|
+
method,
|
|
111
|
+
headers: Object.fromEntries(headers),
|
|
112
|
+
lookup: policy.lookup,
|
|
113
|
+
signal: init.signal ?? void 0
|
|
114
|
+
}, (res) => {
|
|
115
|
+
const out = new Headers();
|
|
116
|
+
for (let i = 0; i < res.rawHeaders.length; i += 2) out.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
|
|
117
|
+
const status = res.statusCode ?? 502;
|
|
118
|
+
const nullBody = NULL_BODY.has(status) || method === "HEAD";
|
|
119
|
+
if (nullBody) res.resume();
|
|
120
|
+
resolve(new Response(nullBody ? null : Readable.toWeb(res), {
|
|
121
|
+
status,
|
|
122
|
+
statusText: res.statusMessage,
|
|
123
|
+
headers: out
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
req.on("error", (err) => reject(err.code === "EPRIVATE" ? errors.forbidden("Address is private") : err));
|
|
127
|
+
req.end(body);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** GET a URL through a `policyFetch`, following redirects (each one checked again) and capping the body. */
|
|
131
|
+
async function publicGet(fetchFn, rawUrl, options) {
|
|
132
|
+
let url = new URL(rawUrl);
|
|
133
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 3e4);
|
|
134
|
+
for (let redirects = 0;; redirects++) {
|
|
135
|
+
const res = await fetchFn(url, {
|
|
136
|
+
redirect: "manual",
|
|
137
|
+
signal,
|
|
138
|
+
headers: options.headers
|
|
139
|
+
});
|
|
140
|
+
const location = res.headers.get("location");
|
|
141
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
142
|
+
await res.body?.cancel();
|
|
143
|
+
if (redirects >= (options.maxRedirects ?? 5)) throw errors.notFound("Too many redirects");
|
|
144
|
+
url = new URL(location, url);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (Number(res.headers.get("content-length")) > options.maxBytes && !options.truncate) {
|
|
148
|
+
await res.body?.cancel();
|
|
149
|
+
throw errors.tooLarge("Remote response is too large");
|
|
150
|
+
}
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
for await (const chunk of res.body ?? []) {
|
|
154
|
+
size += chunk.length;
|
|
155
|
+
if (size > options.maxBytes) {
|
|
156
|
+
await res.body?.cancel().catch(() => {});
|
|
157
|
+
if (!options.truncate) throw errors.tooLarge("Remote response is too large");
|
|
158
|
+
chunks.push(chunk.subarray(0, chunk.length - (size - options.maxBytes)));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
chunks.push(chunk);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: res.status,
|
|
165
|
+
headers: res.headers,
|
|
166
|
+
body: Buffer.concat(chunks),
|
|
167
|
+
url
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { addressPolicy, checkLiteral, isPrivateAddress, policyFetch, publicGet };
|
package/dist/core/push.js
CHANGED
|
@@ -106,6 +106,7 @@ var PushService = class {
|
|
|
106
106
|
const pl = powerLevels(state, version);
|
|
107
107
|
const senderLevel = userLevel(pl, event.sender);
|
|
108
108
|
const thread = threadIdOf(event);
|
|
109
|
+
const notified = [];
|
|
109
110
|
for (const userId of members) {
|
|
110
111
|
if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
|
|
111
112
|
const actions = evaluateRules(await loadRules(this.hs, userId), {
|
|
@@ -119,17 +120,33 @@ var PushService = class {
|
|
|
119
120
|
});
|
|
120
121
|
if (!actions || !actions.includes("notify")) continue;
|
|
121
122
|
const tweaks = tweaksOf(actions);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
notified.push({
|
|
124
|
+
userId,
|
|
125
|
+
tweaks,
|
|
126
|
+
row: {
|
|
127
|
+
user_id: userId,
|
|
128
|
+
room_id: event.room_id,
|
|
129
|
+
event_id: event.event_id,
|
|
130
|
+
stream: ev.stream,
|
|
131
|
+
highlight: tweaks.highlight === true,
|
|
132
|
+
thread_id: thread,
|
|
133
|
+
actions,
|
|
134
|
+
ts: Date.now()
|
|
135
|
+
}
|
|
131
136
|
});
|
|
132
|
-
|
|
137
|
+
}
|
|
138
|
+
if (notified.length === 0) return;
|
|
139
|
+
await this.hs.transaction(async (db) => {
|
|
140
|
+
for (const n of notified) await db.upsert("notifications", n.row);
|
|
141
|
+
});
|
|
142
|
+
const pushers = await this.hs.db.findMany("pushers", { where: [{
|
|
143
|
+
field: "user_id",
|
|
144
|
+
operator: "in",
|
|
145
|
+
value: notified.map((n) => n.userId)
|
|
146
|
+
}] });
|
|
147
|
+
for (const n of notified) {
|
|
148
|
+
const own = pushers.filter((p) => p.user_id === n.userId);
|
|
149
|
+
if (own.length) this.deliver(n.userId, ev, state, n.tweaks, own).catch((err) => this.hs.logger.warn("Push delivery failed", err));
|
|
133
150
|
}
|
|
134
151
|
}
|
|
135
152
|
/** Unread notification counts since the user's read receipts. */
|
|
@@ -196,12 +213,7 @@ var PushService = class {
|
|
|
196
213
|
threads
|
|
197
214
|
};
|
|
198
215
|
}
|
|
199
|
-
async deliver(userId, ev, state, tweaks) {
|
|
200
|
-
const pushers = await this.hs.db.findMany("pushers", { where: [{
|
|
201
|
-
field: "user_id",
|
|
202
|
-
value: userId
|
|
203
|
-
}] });
|
|
204
|
-
if (pushers.length === 0) return;
|
|
216
|
+
async deliver(userId, ev, state, tweaks, pushers) {
|
|
205
217
|
const event = ev.pdu;
|
|
206
218
|
const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
|
|
207
219
|
for (const row of pushers) {
|
|
@@ -234,7 +246,7 @@ var PushService = class {
|
|
|
234
246
|
notification.user_is_target = event.state_key === userId;
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
|
-
const res = await this.hs.
|
|
249
|
+
const res = await this.hs.publicFetch(url, {
|
|
238
250
|
method: "POST",
|
|
239
251
|
headers: { "content-type": "application/json" },
|
|
240
252
|
body: JSON.stringify({ notification }),
|
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 };
|