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.
@@ -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()` or
5
- * `redisCluster()` to run several instances behind a load balancer.
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
- private published;
48
- private transition;
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
- private publishState;
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. */
@@ -91,38 +91,64 @@ var StreamManager = class {
91
91
  cluster;
92
92
  instanceId;
93
93
  inflight = /* @__PURE__ */ new Set();
94
- published = {
95
- busy: false,
96
- pos: 0
97
- };
98
- transition = Promise.resolve();
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.publishState().catch(() => {}), WRITER_TTL_MS / 3);
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
- async publishState() {
111
- await this.cluster.set(this.key(), this.published, WRITER_TTL_MS);
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
- await (this.transition = this.transition.then(async () => {
116
- if (!this.published.busy) {
117
- this.published = {
118
- busy: true,
119
- pos: await this.cluster.currentId("stream")
120
- };
121
- await this.publishState();
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 (this.transition = this.transition.then(async () => {
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
+ "&": "&amp;",
6
+ "<": "&lt;",
7
+ ">": "&gt;",
8
+ "\"": "&quot;",
9
+ "'": "&#39;"
10
+ })[c]);
11
+ }
12
+ //#endregion
13
+ export { escapeHtml };
@@ -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.fetch(`${this.base(idServer)}${path}`, {
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);
@@ -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
- changesSince(userId: string, since: number, upTo: number): Promise<{
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", { where: [
103
- {
104
- field: "user_id",
105
- value: userId
106
- },
107
- {
108
- field: "device_id",
109
- value: deviceId
110
- },
111
- {
112
- field: "fallback",
113
- value: false
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", { where: [
122
- {
123
- field: "user_id",
124
- value: userId
125
- },
126
- {
127
- field: "device_id",
128
- value: deviceId
129
- },
130
- {
131
- field: "used",
132
- value: false
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
- async changesSince(userId, since, upTo) {
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", { where: [{
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.db.findMany("memberships", { where: [{
427
- field: "stream",
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, loadRules } from "./pushrules.js";
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 loadRules(this.hs, userId), {
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
- await this.hs.db.upsert("notifications", {
123
- user_id: userId,
124
- room_id: event.room_id,
125
- event_id: event.event_id,
126
- stream: ev.stream,
127
- highlight: tweaks.highlight === true,
128
- thread_id: threadIdOf(event),
129
- actions,
130
- ts: Date.now()
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
- this.deliver(userId, ev, state, tweaks).catch((err) => this.hs.logger.warn("Push delivery failed", err));
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", { where: [
166
- {
167
- field: "user_id",
168
- value: userId
169
- },
170
- {
171
- field: "room_id",
172
- value: roomId
173
- },
174
- {
175
- field: "stream",
176
- operator: "gt",
177
- value: unthreaded
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.fetch(url, {
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 }),