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/cluster.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
//#region src/core/cluster.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* Coordination between homeserver instances sharing one database. The default
|
|
4
|
-
* (`localCluster()`) keeps everything in process; use `postgresCluster()`
|
|
5
|
-
*
|
|
4
|
+
* (`localCluster()`) keeps everything in process; use `postgresCluster()` to run several
|
|
5
|
+
* instances behind a load balancer.
|
|
6
6
|
*/
|
|
7
7
|
export interface ClusterAdapter {
|
|
8
8
|
/** Allocate the next value of a cluster-wide, strictly increasing sequence. */
|
|
@@ -44,13 +44,20 @@ export declare class StreamManager {
|
|
|
44
44
|
private cluster;
|
|
45
45
|
private instanceId;
|
|
46
46
|
private inflight;
|
|
47
|
-
|
|
48
|
-
private
|
|
47
|
+
/** Allocations waiting for a publish that covers them. */
|
|
48
|
+
private reserving;
|
|
49
|
+
/** Allocations whose publish is done, asking the sequence for their position now. */
|
|
50
|
+
private allocating;
|
|
51
|
+
/** The position of the last published busy state: below every position still being allocated. */
|
|
52
|
+
private lastPos;
|
|
49
53
|
private heartbeat;
|
|
50
54
|
private positionCache;
|
|
55
|
+
private publishing;
|
|
56
|
+
private nextPublish;
|
|
51
57
|
constructor(cluster: ClusterAdapter, instanceId: string);
|
|
52
58
|
private key;
|
|
53
|
-
|
|
59
|
+
/** Publish this writer's state as it is when the publish starts; concurrent callers share it. */
|
|
60
|
+
private flush;
|
|
54
61
|
/** Allocate a position. The caller must call `done()` once its writes are committed. */
|
|
55
62
|
allocate(): Promise<StreamLease>;
|
|
56
63
|
/** Allocate a position, run `fn` with it, then complete it. */
|
package/dist/core/cluster.js
CHANGED
|
@@ -91,38 +91,64 @@ var StreamManager = class {
|
|
|
91
91
|
cluster;
|
|
92
92
|
instanceId;
|
|
93
93
|
inflight = /* @__PURE__ */ new Set();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
/** Allocations waiting for a publish that covers them. */
|
|
95
|
+
reserving = 0;
|
|
96
|
+
/** Allocations whose publish is done, asking the sequence for their position now. */
|
|
97
|
+
allocating = 0;
|
|
98
|
+
/** The position of the last published busy state: below every position still being allocated. */
|
|
99
|
+
lastPos = 0;
|
|
99
100
|
heartbeat;
|
|
100
101
|
positionCache = null;
|
|
102
|
+
publishing = Promise.resolve();
|
|
103
|
+
nextPublish = null;
|
|
101
104
|
constructor(cluster, instanceId) {
|
|
102
105
|
this.cluster = cluster;
|
|
103
106
|
this.instanceId = instanceId;
|
|
104
|
-
this.heartbeat = setInterval(() => void this.
|
|
107
|
+
this.heartbeat = setInterval(() => void this.flush().catch(() => {}), WRITER_TTL_MS / 3);
|
|
105
108
|
this.heartbeat.unref?.();
|
|
106
109
|
}
|
|
107
110
|
key() {
|
|
108
111
|
return `writer:${this.instanceId}`;
|
|
109
112
|
}
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
/** Publish this writer's state as it is when the publish starts; concurrent callers share it. */
|
|
114
|
+
flush() {
|
|
115
|
+
if (this.nextPublish) return this.nextPublish;
|
|
116
|
+
const run = async () => {
|
|
117
|
+
this.nextPublish = null;
|
|
118
|
+
const covered = this.reserving;
|
|
119
|
+
const earlier = this.allocating;
|
|
120
|
+
this.reserving = 0;
|
|
121
|
+
this.allocating += covered;
|
|
122
|
+
const bounds = [];
|
|
123
|
+
if (this.inflight.size) bounds.push(Math.min(...this.inflight) - 1);
|
|
124
|
+
if (earlier) bounds.push(this.lastPos);
|
|
125
|
+
if (covered) bounds.push(await this.cluster.currentId("stream"));
|
|
126
|
+
const state = bounds.length ? {
|
|
127
|
+
busy: true,
|
|
128
|
+
pos: Math.min(...bounds)
|
|
129
|
+
} : {
|
|
130
|
+
busy: false,
|
|
131
|
+
pos: 0
|
|
132
|
+
};
|
|
133
|
+
if (state.busy) this.lastPos = state.pos;
|
|
134
|
+
await this.cluster.set(this.key(), state, WRITER_TTL_MS);
|
|
135
|
+
};
|
|
136
|
+
const p = this.publishing.then(run, run);
|
|
137
|
+
this.nextPublish = p;
|
|
138
|
+
this.publishing = p.catch(() => {});
|
|
139
|
+
return p;
|
|
112
140
|
}
|
|
113
141
|
/** Allocate a position. The caller must call `done()` once its writes are committed. */
|
|
114
142
|
async allocate() {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
const id = await this.cluster.nextId("stream");
|
|
125
|
-
this.inflight.add(id);
|
|
143
|
+
this.reserving++;
|
|
144
|
+
let id;
|
|
145
|
+
try {
|
|
146
|
+
await this.flush();
|
|
147
|
+
id = await this.cluster.nextId("stream");
|
|
148
|
+
this.inflight.add(id);
|
|
149
|
+
} finally {
|
|
150
|
+
this.allocating--;
|
|
151
|
+
}
|
|
126
152
|
let finished = false;
|
|
127
153
|
const timer = setTimeout(() => void lease.done(), 6e4);
|
|
128
154
|
timer.unref?.();
|
|
@@ -133,16 +159,7 @@ var StreamManager = class {
|
|
|
133
159
|
finished = true;
|
|
134
160
|
clearTimeout(timer);
|
|
135
161
|
this.inflight.delete(id);
|
|
136
|
-
await
|
|
137
|
-
this.published = this.inflight.size ? {
|
|
138
|
-
busy: true,
|
|
139
|
-
pos: Math.min(...this.inflight) - 1
|
|
140
|
-
} : {
|
|
141
|
-
busy: false,
|
|
142
|
-
pos: await this.cluster.currentId("stream")
|
|
143
|
-
};
|
|
144
|
-
await this.publishState();
|
|
145
|
-
}));
|
|
162
|
+
await this.flush();
|
|
146
163
|
this.positionCache = null;
|
|
147
164
|
}
|
|
148
165
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/core/html.ts
|
|
2
|
+
/** Escape text for use in HTML content and quoted attributes. */
|
|
3
|
+
function escapeHtml(s) {
|
|
4
|
+
return s.replace(/[&<>"']/g, (c) => ({
|
|
5
|
+
"&": "&",
|
|
6
|
+
"<": "<",
|
|
7
|
+
">": ">",
|
|
8
|
+
"\"": """,
|
|
9
|
+
"'": "'"
|
|
10
|
+
})[c]);
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { escapeHtml };
|
package/dist/core/identity.js
CHANGED
|
@@ -18,7 +18,7 @@ var IdentityServers = class {
|
|
|
18
18
|
if (opts.signed) headers.authorization = this.hs.federation.client.authHeader(idServer, method, path, opts.body);
|
|
19
19
|
let res;
|
|
20
20
|
try {
|
|
21
|
-
res = await this.hs.
|
|
21
|
+
res = await this.hs.publicFetch(`${this.base(idServer)}${path}`, {
|
|
22
22
|
method,
|
|
23
23
|
headers,
|
|
24
24
|
body: opts.body === void 0 ? void 0 : JSON.stringify(opts.body),
|
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
|
@@ -3,8 +3,9 @@ import { M, UNSTABLE } from "./m.js";
|
|
|
3
3
|
import { splitStateKey, toClientEvent } from "./events.js";
|
|
4
4
|
import { getState, powerLevels, userLevel } from "./authrules.js";
|
|
5
5
|
import { globMatch, globSearchWords } from "./glob.js";
|
|
6
|
-
import { KINDS,
|
|
6
|
+
import { KINDS, readOnlyRules } from "./pushrules.js";
|
|
7
7
|
import { isSubscribed } from "../api/threadsubs.js";
|
|
8
|
+
import { upsertMany } from "../storage/batch.js";
|
|
8
9
|
//#region src/core/push.ts
|
|
9
10
|
/** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
|
|
10
11
|
function valueAtPath(obj, path) {
|
|
@@ -106,9 +107,10 @@ var PushService = class {
|
|
|
106
107
|
const pl = powerLevels(state, version);
|
|
107
108
|
const senderLevel = userLevel(pl, event.sender);
|
|
108
109
|
const thread = threadIdOf(event);
|
|
110
|
+
const notified = [];
|
|
109
111
|
for (const userId of members) {
|
|
110
112
|
if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
|
|
111
|
-
const actions = evaluateRules(await
|
|
113
|
+
const actions = evaluateRules(await readOnlyRules(this.hs, userId), {
|
|
112
114
|
event,
|
|
113
115
|
userId,
|
|
114
116
|
displayName: getState(state, M.ROOM.MEMBER, userId)?.content?.displayname ?? null,
|
|
@@ -119,17 +121,31 @@ var PushService = class {
|
|
|
119
121
|
});
|
|
120
122
|
if (!actions || !actions.includes("notify")) continue;
|
|
121
123
|
const tweaks = tweaksOf(actions);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
notified.push({
|
|
125
|
+
userId,
|
|
126
|
+
tweaks,
|
|
127
|
+
row: {
|
|
128
|
+
user_id: userId,
|
|
129
|
+
room_id: event.room_id,
|
|
130
|
+
event_id: event.event_id,
|
|
131
|
+
stream: ev.stream,
|
|
132
|
+
highlight: tweaks.highlight === true,
|
|
133
|
+
thread_id: thread,
|
|
134
|
+
actions,
|
|
135
|
+
ts: Date.now()
|
|
136
|
+
}
|
|
131
137
|
});
|
|
132
|
-
|
|
138
|
+
}
|
|
139
|
+
if (notified.length === 0) return;
|
|
140
|
+
await this.hs.transaction((db) => upsertMany(db, "notifications", notified.map((n) => n.row)));
|
|
141
|
+
const pushers = await this.hs.db.findMany("pushers", { where: [{
|
|
142
|
+
field: "user_id",
|
|
143
|
+
operator: "in",
|
|
144
|
+
value: notified.map((n) => n.userId)
|
|
145
|
+
}] });
|
|
146
|
+
for (const n of notified) {
|
|
147
|
+
const own = pushers.filter((p) => p.user_id === n.userId);
|
|
148
|
+
if (own.length) this.deliver(n.userId, ev, state, n.tweaks, own).catch((err) => this.hs.logger.warn("Push delivery failed", err));
|
|
133
149
|
}
|
|
134
150
|
}
|
|
135
151
|
/** Unread notification counts since the user's read receipts. */
|
|
@@ -162,21 +178,28 @@ var PushService = class {
|
|
|
162
178
|
async unreadCounts(userId, roomId) {
|
|
163
179
|
const read = await this.readPositions(userId, roomId);
|
|
164
180
|
const unthreaded = read.unthreaded;
|
|
165
|
-
const rows = await this.hs.db.findMany("notifications", {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
const rows = await this.hs.db.findMany("notifications", {
|
|
182
|
+
where: [
|
|
183
|
+
{
|
|
184
|
+
field: "user_id",
|
|
185
|
+
value: userId
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
field: "room_id",
|
|
189
|
+
value: roomId
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
field: "stream",
|
|
193
|
+
operator: "gt",
|
|
194
|
+
value: unthreaded
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
fields: [
|
|
198
|
+
"stream",
|
|
199
|
+
"thread_id",
|
|
200
|
+
"highlight"
|
|
201
|
+
]
|
|
202
|
+
});
|
|
180
203
|
const room = {
|
|
181
204
|
notification_count: 0,
|
|
182
205
|
highlight_count: 0
|
|
@@ -196,12 +219,7 @@ var PushService = class {
|
|
|
196
219
|
threads
|
|
197
220
|
};
|
|
198
221
|
}
|
|
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;
|
|
222
|
+
async deliver(userId, ev, state, tweaks, pushers) {
|
|
205
223
|
const event = ev.pdu;
|
|
206
224
|
const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
|
|
207
225
|
for (const row of pushers) {
|
|
@@ -234,7 +252,7 @@ var PushService = class {
|
|
|
234
252
|
notification.user_is_target = event.state_key === userId;
|
|
235
253
|
}
|
|
236
254
|
}
|
|
237
|
-
const res = await this.hs.
|
|
255
|
+
const res = await this.hs.publicFetch(url, {
|
|
238
256
|
method: "POST",
|
|
239
257
|
headers: { "content-type": "application/json" },
|
|
240
258
|
body: JSON.stringify({ notification }),
|