matrix-nodejs-homeserver 0.0.0-stage → 0.3.1
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/LICENSE +75 -0
- package/README.md +479 -2
- package/dist/adapters/auth.d.ts +50 -0
- package/dist/adapters/auth.js +18 -0
- package/dist/adapters/cluster.d.ts +55 -0
- package/dist/adapters/cluster.js +249 -0
- package/dist/adapters/media.d.ts +50 -0
- package/dist/adapters/media.js +63 -0
- package/dist/adapters/s3.d.ts +33 -0
- package/dist/adapters/s3.js +95 -0
- package/dist/api/account.js +594 -0
- package/dist/api/admin.js +267 -0
- package/dist/api/appservice.js +45 -0
- package/dist/api/backup.js +280 -0
- package/dist/api/federation.js +826 -0
- package/dist/api/filters.js +88 -0
- package/dist/api/index.js +47 -0
- package/dist/api/legacy.js +183 -0
- package/dist/api/media.js +271 -0
- package/dist/api/misc.js +95 -0
- package/dist/api/preview.js +201 -0
- package/dist/api/profile.js +176 -0
- package/dist/api/push.js +202 -0
- package/dist/api/relationships.js +270 -0
- package/dist/api/rooms.js +1487 -0
- package/dist/api/rtc.d.ts +23 -0
- package/dist/api/rtc.js +66 -0
- package/dist/api/search.js +161 -0
- package/dist/api/slidingsync.js +474 -0
- package/dist/api/spaces.js +177 -0
- package/dist/api/sync.js +609 -0
- package/dist/api/threadsubs.js +140 -0
- package/dist/api/threepid.js +155 -0
- package/dist/api/uia.js +127 -0
- package/dist/api/user.js +329 -0
- package/dist/core/admin.d.ts +84 -0
- package/dist/core/admin.js +308 -0
- package/dist/core/adminroom.d.ts +49 -0
- package/dist/core/adminroom.js +483 -0
- package/dist/core/appservices.d.ts +74 -0
- package/dist/core/appservices.js +369 -0
- package/dist/core/audit.d.ts +50 -0
- package/dist/core/audit.js +86 -0
- package/dist/core/auth.d.ts +48 -0
- package/dist/core/auth.js +237 -0
- package/dist/core/authrules.d.ts +4 -0
- package/dist/core/authrules.js +348 -0
- package/dist/core/cluster.d.ts +66 -0
- package/dist/core/cluster.js +191 -0
- package/dist/core/crypto.d.ts +10 -0
- package/dist/core/crypto.js +100 -0
- package/dist/core/delayed.d.ts +39 -0
- package/dist/core/delayed.js +160 -0
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +45 -0
- package/dist/core/events.d.ts +32 -0
- package/dist/core/events.js +186 -0
- package/dist/core/glob.js +66 -0
- package/dist/core/identity.d.ts +24 -0
- package/dist/core/identity.js +96 -0
- package/dist/core/ids.js +36 -0
- package/dist/core/invitefilter.js +36 -0
- package/dist/core/json.js +16 -0
- package/dist/core/keys.d.ts +36 -0
- package/dist/core/keys.js +450 -0
- package/dist/core/m.d.ts +209 -0
- package/dist/core/m.js +182 -0
- package/dist/core/notifier.d.ts +17 -0
- package/dist/core/notifier.js +54 -0
- package/dist/core/oauth.d.ts +38 -0
- package/dist/core/oauth.js +108 -0
- package/dist/core/partialstate.d.ts +32 -0
- package/dist/core/partialstate.js +198 -0
- package/dist/core/phone.js +268 -0
- package/dist/core/policy.d.ts +29 -0
- package/dist/core/policy.js +97 -0
- package/dist/core/profile.js +39 -0
- package/dist/core/push.d.ts +46 -0
- package/dist/core/push.js +307 -0
- package/dist/core/pushrules.js +229 -0
- package/dist/core/ratelimit.d.ts +23 -0
- package/dist/core/ratelimit.js +77 -0
- package/dist/core/rooms.d.ts +215 -0
- package/dist/core/rooms.js +1731 -0
- package/dist/core/settings.d.ts +11 -0
- package/dist/core/settings.js +29 -0
- package/dist/core/sso.d.ts +46 -0
- package/dist/core/sso.js +165 -0
- package/dist/core/stateres.d.ts +4 -0
- package/dist/core/stateres.js +209 -0
- package/dist/core/threepid.d.ts +80 -0
- package/dist/core/threepid.js +275 -0
- package/dist/core/typing.d.ts +16 -0
- package/dist/core/typing.js +60 -0
- package/dist/federation/client.d.ts +51 -0
- package/dist/federation/client.js +279 -0
- package/dist/federation/index.d.ts +153 -0
- package/dist/federation/index.js +1059 -0
- package/dist/federation/keyring.d.ts +26 -0
- package/dist/federation/keyring.js +122 -0
- package/dist/federation/sender.d.ts +26 -0
- package/dist/federation/sender.js +164 -0
- package/dist/homeserver.d.ts +224 -0
- package/dist/homeserver.js +302 -0
- package/dist/http/adapters.d.ts +40 -0
- package/dist/http/adapters.js +130 -0
- package/dist/http/handler.d.ts +8 -0
- package/dist/http/handler.js +181 -0
- package/dist/http/input.js +24 -0
- package/dist/http/procedure.js +25 -0
- package/dist/http/router.d.ts +43 -0
- package/dist/http/router.js +56 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +15 -0
- package/dist/storage/memory.d.ts +5 -0
- package/dist/storage/memory.js +122 -0
- package/dist/storage/schema.d.ts +4 -0
- package/dist/storage/schema.js +664 -0
- package/dist/storage/sql.d.ts +72 -0
- package/dist/storage/sql.js +274 -0
- package/dist/storage/types.d.ts +55 -0
- package/dist/vendor/zod/LICENSE +21 -0
- package/dist/vendor/zod/v4/classic/errors.js +54 -0
- package/dist/vendor/zod/v4/classic/parse.js +17 -0
- package/dist/vendor/zod/v4/classic/schemas.js +865 -0
- package/dist/vendor/zod/v4/core/api.js +512 -0
- package/dist/vendor/zod/v4/core/checks.js +300 -0
- package/dist/vendor/zod/v4/core/core.js +109 -0
- package/dist/vendor/zod/v4/core/doc.js +35 -0
- package/dist/vendor/zod/v4/core/errors.js +118 -0
- package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
- package/dist/vendor/zod/v4/core/memoizer.js +255 -0
- package/dist/vendor/zod/v4/core/parse.js +206 -0
- package/dist/vendor/zod/v4/core/regexes.js +73 -0
- package/dist/vendor/zod/v4/core/registries.js +48 -0
- package/dist/vendor/zod/v4/core/schemas.js +1378 -0
- package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
- package/dist/vendor/zod/v4/core/util.js +537 -0
- package/dist/vendor/zod/v4/core/versions.js +8 -0
- package/dist/vendor/zod/v4/locales/en.js +110 -0
- package/package.json +65 -4
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { isObject } from "./json.js";
|
|
2
|
+
import { M, UNSTABLE } from "./m.js";
|
|
3
|
+
import { splitStateKey, toClientEvent } from "./events.js";
|
|
4
|
+
import { getState, powerLevels, userLevel } from "./authrules.js";
|
|
5
|
+
import { globMatch, globSearchWords } from "./glob.js";
|
|
6
|
+
import { KINDS, loadRules } from "./pushrules.js";
|
|
7
|
+
import { isSubscribed } from "../api/threadsubs.js";
|
|
8
|
+
//#region src/core/push.ts
|
|
9
|
+
/** Look up a dotted path; `\.` escapes a literal dot in a key (e.g. `content.m\.relates_to`). */
|
|
10
|
+
function valueAtPath(obj, path) {
|
|
11
|
+
const parts = path.split(/(?<!\\)\./).map((p) => p.replace(/\\\./g, ".").replace(/\\\\/g, "\\"));
|
|
12
|
+
let cur = obj;
|
|
13
|
+
for (const p of parts) {
|
|
14
|
+
if (!isObject(cur) || !(p in cur)) return void 0;
|
|
15
|
+
cur = cur[p];
|
|
16
|
+
}
|
|
17
|
+
return cur;
|
|
18
|
+
}
|
|
19
|
+
function conditionMatches(cond, ctx) {
|
|
20
|
+
switch (cond.kind) {
|
|
21
|
+
case "event_match": {
|
|
22
|
+
if (typeof cond.key !== "string" || typeof cond.pattern !== "string") return false;
|
|
23
|
+
const value = valueAtPath(ctx.event, cond.key);
|
|
24
|
+
if (typeof value !== "string") return false;
|
|
25
|
+
return cond.key === "content.body" ? globSearchWords(cond.pattern, value) : globMatch(cond.pattern, value, { caseInsensitive: true });
|
|
26
|
+
}
|
|
27
|
+
case "event_property_is": return typeof cond.key === "string" && valueAtPath(ctx.event, cond.key) === cond.value;
|
|
28
|
+
case "event_property_contains": {
|
|
29
|
+
const value = typeof cond.key === "string" ? valueAtPath(ctx.event, cond.key) : void 0;
|
|
30
|
+
return Array.isArray(value) && value.includes(cond.value);
|
|
31
|
+
}
|
|
32
|
+
case "contains_display_name": {
|
|
33
|
+
const body = ctx.event.content?.body;
|
|
34
|
+
if (!ctx.displayName || typeof body !== "string") return false;
|
|
35
|
+
const escaped = ctx.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
return new RegExp(`(^|\\W)${escaped}(\\W|$)`, "i").test(body);
|
|
37
|
+
}
|
|
38
|
+
case "room_member_count": {
|
|
39
|
+
const m = /^(==|<=|>=|<|>)?(\d+)$/.exec(String(cond.is ?? ""));
|
|
40
|
+
if (!m) return false;
|
|
41
|
+
const n = Number(m[2]);
|
|
42
|
+
switch (m[1] ?? "==") {
|
|
43
|
+
case "==": return ctx.memberCount === n;
|
|
44
|
+
case "<": return ctx.memberCount < n;
|
|
45
|
+
case ">": return ctx.memberCount > n;
|
|
46
|
+
case "<=": return ctx.memberCount <= n;
|
|
47
|
+
case ">=": return ctx.memberCount >= n;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
case UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION: return ctx.threadSubscribed !== void 0 && ctx.threadSubscribed !== null && ctx.threadSubscribed === cond.subscribed;
|
|
52
|
+
case "sender_notification_permission": return typeof cond.key === "string" && ctx.senderLevel >= (ctx.notificationLevels[cond.key] ?? 50);
|
|
53
|
+
default: return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function ruleMatches(kind, rule, ctx) {
|
|
57
|
+
if (!rule.enabled) return false;
|
|
58
|
+
switch (kind) {
|
|
59
|
+
case "override":
|
|
60
|
+
case "postcontent":
|
|
61
|
+
case "underride": return (rule.conditions ?? []).every((c) => conditionMatches(c, ctx));
|
|
62
|
+
case "content": return typeof rule.pattern === "string" && conditionMatches({
|
|
63
|
+
kind: "event_match",
|
|
64
|
+
key: "content.body",
|
|
65
|
+
pattern: rule.pattern
|
|
66
|
+
}, ctx);
|
|
67
|
+
case "room": return ctx.event.room_id === rule.rule_id;
|
|
68
|
+
case "sender": return ctx.event.sender === rule.rule_id;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/** Evaluate a user's push rules against an event; returns the actions of the first matching rule. */
|
|
73
|
+
function evaluateRules(rules, ctx) {
|
|
74
|
+
for (const kind of KINDS) for (const rule of rules[kind] ?? []) if (ruleMatches(kind, rule, ctx)) return rule.actions ?? [];
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function tweaksOf(actions) {
|
|
78
|
+
const tweaks = {};
|
|
79
|
+
for (const a of actions) if (isObject(a) && typeof a.set_tweak === "string") tweaks[a.set_tweak] = "value" in a ? a.value : true;
|
|
80
|
+
return tweaks;
|
|
81
|
+
}
|
|
82
|
+
/** Thread an event belongs to for notification purposes ("main" for the main timeline). */
|
|
83
|
+
function threadIdOf(event) {
|
|
84
|
+
const rel = event.content?.[M.RELATES_TO];
|
|
85
|
+
return isObject(rel) && rel.rel_type === M.THREAD && typeof rel.event_id === "string" ? rel.event_id : "main";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Push rule evaluation and push gateway delivery. Every new event is evaluated for
|
|
89
|
+
* every local member; matches are stored for unread counts and `/notifications`.
|
|
90
|
+
*/
|
|
91
|
+
var PushService = class {
|
|
92
|
+
hs;
|
|
93
|
+
constructor(hs) {
|
|
94
|
+
this.hs = hs;
|
|
95
|
+
}
|
|
96
|
+
async onEvent(ev, state, version) {
|
|
97
|
+
const event = ev.pdu;
|
|
98
|
+
const members = [];
|
|
99
|
+
for (const [key, member] of state) {
|
|
100
|
+
const [type, userId] = splitStateKey(key);
|
|
101
|
+
if (type !== M.ROOM.MEMBER) continue;
|
|
102
|
+
const m = member.content?.membership;
|
|
103
|
+
if (m === "join" || m === "invite" && event.type === M.ROOM.MEMBER && event.state_key === userId) members.push(userId);
|
|
104
|
+
}
|
|
105
|
+
const joined = members.filter((u) => getState(state, M.ROOM.MEMBER, u)?.content?.membership === "join");
|
|
106
|
+
const pl = powerLevels(state, version);
|
|
107
|
+
const senderLevel = userLevel(pl, event.sender);
|
|
108
|
+
const thread = threadIdOf(event);
|
|
109
|
+
for (const userId of members) {
|
|
110
|
+
if (userId === event.sender || !this.hs.isLocalUser(userId)) continue;
|
|
111
|
+
const actions = evaluateRules(await loadRules(this.hs, userId), {
|
|
112
|
+
event,
|
|
113
|
+
userId,
|
|
114
|
+
displayName: getState(state, M.ROOM.MEMBER, userId)?.content?.displayname ?? null,
|
|
115
|
+
memberCount: joined.length,
|
|
116
|
+
senderLevel: Number.isFinite(senderLevel) ? senderLevel : Number.MAX_SAFE_INTEGER,
|
|
117
|
+
notificationLevels: pl.notifications,
|
|
118
|
+
threadSubscribed: thread === "main" ? void 0 : await isSubscribed(this.hs, userId, event.room_id, thread)
|
|
119
|
+
});
|
|
120
|
+
if (!actions || !actions.includes("notify")) continue;
|
|
121
|
+
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()
|
|
131
|
+
});
|
|
132
|
+
this.deliver(userId, ev, state, tweaks).catch((err) => this.hs.logger.warn("Push delivery failed", err));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Unread notification counts since the user's read receipts. */
|
|
136
|
+
/** Stream positions up to which the user has read the room (unthreaded) and each thread. */
|
|
137
|
+
async readPositions(userId, roomId) {
|
|
138
|
+
const receipts = await this.hs.db.findMany("receipts", { where: [{
|
|
139
|
+
field: "room_id",
|
|
140
|
+
value: roomId
|
|
141
|
+
}, {
|
|
142
|
+
field: "user_id",
|
|
143
|
+
value: userId
|
|
144
|
+
}] });
|
|
145
|
+
let unthreaded = 0;
|
|
146
|
+
const threadRead = /* @__PURE__ */ new Map();
|
|
147
|
+
for (const r of receipts) {
|
|
148
|
+
if (r.receipt_type !== M.READ._ && r.receipt_type !== M.READ.PRIVATE) continue;
|
|
149
|
+
const target = await this.hs.rooms.getEvent(r.event_id);
|
|
150
|
+
if (!target) continue;
|
|
151
|
+
if (r.thread_id === "") unthreaded = Math.max(unthreaded, target.stream);
|
|
152
|
+
else threadRead.set(r.thread_id, Math.max(threadRead.get(r.thread_id) ?? 0, target.stream));
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
unthreaded,
|
|
156
|
+
threads: threadRead
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
isRead(row, read) {
|
|
160
|
+
return row.stream <= read.unthreaded || row.stream <= (read.threads.get(row.thread_id) ?? 0);
|
|
161
|
+
}
|
|
162
|
+
async unreadCounts(userId, roomId) {
|
|
163
|
+
const read = await this.readPositions(userId, roomId);
|
|
164
|
+
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
|
+
] });
|
|
180
|
+
const room = {
|
|
181
|
+
notification_count: 0,
|
|
182
|
+
highlight_count: 0
|
|
183
|
+
};
|
|
184
|
+
const threads = {};
|
|
185
|
+
for (const n of rows) {
|
|
186
|
+
if (this.isRead(n, read)) continue;
|
|
187
|
+
const bucket = n.thread_id === "main" ? room : threads[n.thread_id] ??= {
|
|
188
|
+
notification_count: 0,
|
|
189
|
+
highlight_count: 0
|
|
190
|
+
};
|
|
191
|
+
bucket.notification_count++;
|
|
192
|
+
if (n.highlight) bucket.highlight_count++;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
room,
|
|
196
|
+
threads
|
|
197
|
+
};
|
|
198
|
+
}
|
|
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;
|
|
205
|
+
const event = ev.pdu;
|
|
206
|
+
const unread = (await this.unreadCounts(userId, event.room_id)).room.notification_count;
|
|
207
|
+
for (const row of pushers) {
|
|
208
|
+
const pusher = row.pusher;
|
|
209
|
+
if (pusher.kind !== "http" || typeof pusher.data?.url !== "string") continue;
|
|
210
|
+
const { url, format, ...data } = pusher.data;
|
|
211
|
+
const notification = {
|
|
212
|
+
event_id: event.event_id,
|
|
213
|
+
room_id: event.room_id,
|
|
214
|
+
counts: { unread },
|
|
215
|
+
devices: [{
|
|
216
|
+
app_id: pusher.app_id,
|
|
217
|
+
pushkey: pusher.pushkey,
|
|
218
|
+
pushkey_ts: Math.floor(Date.now() / 1e3),
|
|
219
|
+
data,
|
|
220
|
+
tweaks
|
|
221
|
+
}],
|
|
222
|
+
prio: tweaks.highlight || tweaks.sound ? "high" : "low"
|
|
223
|
+
};
|
|
224
|
+
if (format !== "event_id_only") {
|
|
225
|
+
notification.type = event.type;
|
|
226
|
+
notification.sender = event.sender;
|
|
227
|
+
notification.content = event.content;
|
|
228
|
+
const name = getState(state, M.ROOM.NAME)?.content?.name;
|
|
229
|
+
if (typeof name === "string") notification.room_name = name;
|
|
230
|
+
const senderName = getState(state, M.ROOM.MEMBER, event.sender)?.content?.displayname;
|
|
231
|
+
if (typeof senderName === "string") notification.sender_display_name = senderName;
|
|
232
|
+
if (event.type === M.ROOM.MEMBER) {
|
|
233
|
+
notification.membership = event.content?.membership;
|
|
234
|
+
notification.user_is_target = event.state_key === userId;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const res = await this.hs.fetch(url, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "content-type": "application/json" },
|
|
240
|
+
body: JSON.stringify({ notification }),
|
|
241
|
+
signal: AbortSignal.timeout(1e4)
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
this.hs.logger.warn("Push gateway returned", res.status, "for", url);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const body = await res.json().catch(() => ({}));
|
|
248
|
+
if (Array.isArray(body.rejected) && body.rejected.includes(pusher.pushkey)) await this.hs.db.delete("pushers", [
|
|
249
|
+
{
|
|
250
|
+
field: "user_id",
|
|
251
|
+
value: userId
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
field: "app_id",
|
|
255
|
+
value: pusher.app_id
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
field: "pushkey",
|
|
259
|
+
value: pusher.pushkey
|
|
260
|
+
}
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Rows for `GET /notifications`. */
|
|
265
|
+
async list(userId, opts) {
|
|
266
|
+
const where = [{
|
|
267
|
+
field: "user_id",
|
|
268
|
+
value: userId
|
|
269
|
+
}];
|
|
270
|
+
if (opts.from !== null) where.push({
|
|
271
|
+
field: "stream",
|
|
272
|
+
operator: "lt",
|
|
273
|
+
value: opts.from
|
|
274
|
+
});
|
|
275
|
+
if (opts.onlyHighlight) where.push({
|
|
276
|
+
field: "highlight",
|
|
277
|
+
value: true
|
|
278
|
+
});
|
|
279
|
+
const rows = await this.hs.db.findMany("notifications", {
|
|
280
|
+
where,
|
|
281
|
+
sortBy: {
|
|
282
|
+
field: "stream",
|
|
283
|
+
direction: "desc"
|
|
284
|
+
},
|
|
285
|
+
limit: opts.limit
|
|
286
|
+
});
|
|
287
|
+
const out = [];
|
|
288
|
+
const readByRoom = /* @__PURE__ */ new Map();
|
|
289
|
+
for (const row of rows) {
|
|
290
|
+
const ev = await this.hs.rooms.getEvent(row.event_id);
|
|
291
|
+
if (!ev) continue;
|
|
292
|
+
let read = readByRoom.get(row.room_id);
|
|
293
|
+
if (!read) readByRoom.set(row.room_id, read = await this.readPositions(userId, row.room_id));
|
|
294
|
+
out.push({
|
|
295
|
+
actions: row.actions,
|
|
296
|
+
event: toClientEvent(ev.pdu),
|
|
297
|
+
room_id: row.room_id,
|
|
298
|
+
ts: row.ts,
|
|
299
|
+
read: this.isRead(row, read),
|
|
300
|
+
stream: row.stream
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
//#endregion
|
|
307
|
+
export { PushService, evaluateRules, threadIdOf, tweaksOf, valueAtPath };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { isObject } from "./json.js";
|
|
2
|
+
import { M, UNSTABLE } from "./m.js";
|
|
3
|
+
//#region src/core/pushrules.ts
|
|
4
|
+
const KINDS = [
|
|
5
|
+
"override",
|
|
6
|
+
"content",
|
|
7
|
+
"postcontent",
|
|
8
|
+
"room",
|
|
9
|
+
"sender",
|
|
10
|
+
"underride"
|
|
11
|
+
];
|
|
12
|
+
function defaultRules(userId) {
|
|
13
|
+
const localpart = userId.slice(1).split(":")[0];
|
|
14
|
+
const notify = ["notify"];
|
|
15
|
+
const sound = (s) => ({
|
|
16
|
+
set_tweak: "sound",
|
|
17
|
+
value: s
|
|
18
|
+
});
|
|
19
|
+
const highlight = { set_tweak: "highlight" };
|
|
20
|
+
const r = (rule_id, conditions, actions, extra = {}) => ({
|
|
21
|
+
rule_id,
|
|
22
|
+
default: true,
|
|
23
|
+
enabled: true,
|
|
24
|
+
actions,
|
|
25
|
+
...conditions ? { conditions } : {},
|
|
26
|
+
...extra
|
|
27
|
+
});
|
|
28
|
+
const em = (key, pattern) => ({
|
|
29
|
+
kind: "event_match",
|
|
30
|
+
key,
|
|
31
|
+
pattern
|
|
32
|
+
});
|
|
33
|
+
const ep = (key, value) => ({
|
|
34
|
+
kind: "event_property_is",
|
|
35
|
+
key,
|
|
36
|
+
value
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
override: [
|
|
40
|
+
r(M.RULE.MASTER, [], [], { enabled: false }),
|
|
41
|
+
r(M.RULE.SUPPRESS_NOTICES, [em("content.msgtype", M.NOTICE)], []),
|
|
42
|
+
r(M.RULE.INVITE_FOR_ME, [
|
|
43
|
+
em("type", M.ROOM.MEMBER),
|
|
44
|
+
em("content.membership", "invite"),
|
|
45
|
+
em("state_key", userId)
|
|
46
|
+
], [...notify, sound("default")]),
|
|
47
|
+
r(M.RULE.MEMBER_EVENT, [em("type", M.ROOM.MEMBER)], []),
|
|
48
|
+
r(M.RULE.IS_USER_MENTION, [{
|
|
49
|
+
kind: "event_property_contains",
|
|
50
|
+
key: "content.m\\.mentions.user_ids",
|
|
51
|
+
value: userId
|
|
52
|
+
}], [
|
|
53
|
+
...notify,
|
|
54
|
+
sound("default"),
|
|
55
|
+
highlight
|
|
56
|
+
]),
|
|
57
|
+
r(M.RULE.CONTAINS_DISPLAY_NAME, [{ kind: "contains_display_name" }], [
|
|
58
|
+
...notify,
|
|
59
|
+
sound("default"),
|
|
60
|
+
highlight
|
|
61
|
+
]),
|
|
62
|
+
r(M.RULE.IS_ROOM_MENTION, [ep("content.m\\.mentions.room", true), {
|
|
63
|
+
kind: "sender_notification_permission",
|
|
64
|
+
key: "room"
|
|
65
|
+
}], [...notify, highlight]),
|
|
66
|
+
r(M.RULE.ROOMNOTIF, [em("content.body", "@room"), {
|
|
67
|
+
kind: "sender_notification_permission",
|
|
68
|
+
key: "room"
|
|
69
|
+
}], [...notify, highlight]),
|
|
70
|
+
r(M.RULE.TOMBSTONE, [em("type", M.ROOM.TOMBSTONE), em("state_key", "")], [...notify, highlight]),
|
|
71
|
+
r(M.RULE.REACTION, [em("type", M.REACTION)], []),
|
|
72
|
+
r(M.RULE.ROOM.SERVER_ACL, [em("type", M.ROOM.SERVER_ACL), em("state_key", "")], []),
|
|
73
|
+
r(M.RULE.SUPPRESS_EDITS, [ep("content.m\\.relates_to.rel_type", M.REPLACE)], []),
|
|
74
|
+
r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_RESPONSE, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.RESPONSE)], [])
|
|
75
|
+
],
|
|
76
|
+
content: [r(M.RULE.CONTAINS_USER_NAME, void 0, [
|
|
77
|
+
...notify,
|
|
78
|
+
sound("default"),
|
|
79
|
+
highlight
|
|
80
|
+
], { pattern: localpart })],
|
|
81
|
+
postcontent: [r(UNSTABLE.IO.ELEMENT.MSC4306.RULE.UNSUBSCRIBED_THREAD, [{
|
|
82
|
+
kind: UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION,
|
|
83
|
+
subscribed: false
|
|
84
|
+
}], []), r(UNSTABLE.IO.ELEMENT.MSC4306.RULE.SUBSCRIBED_THREAD, [{
|
|
85
|
+
kind: UNSTABLE.IO.ELEMENT.MSC4306.THREAD_SUBSCRIPTION,
|
|
86
|
+
subscribed: true
|
|
87
|
+
}], [...notify, sound("default")])],
|
|
88
|
+
room: [],
|
|
89
|
+
sender: [],
|
|
90
|
+
underride: [
|
|
91
|
+
r(M.RULE.CALL, [em("type", M.CALL.INVITE)], [...notify, sound("ring")]),
|
|
92
|
+
r(M.RULE.ENCRYPTED_ROOM_ONE_TO_ONE, [{
|
|
93
|
+
kind: "room_member_count",
|
|
94
|
+
is: "2"
|
|
95
|
+
}, em("type", M.ROOM.ENCRYPTED)], [...notify, sound("default")]),
|
|
96
|
+
r(M.RULE.ROOM_ONE_TO_ONE, [{
|
|
97
|
+
kind: "room_member_count",
|
|
98
|
+
is: "2"
|
|
99
|
+
}, em("type", M.ROOM.MESSAGE)], [...notify, sound("default")]),
|
|
100
|
+
r(M.RULE.MESSAGE, [em("type", M.ROOM.MESSAGE)], notify),
|
|
101
|
+
r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_START_ONE_TO_ONE, [{
|
|
102
|
+
kind: "room_member_count",
|
|
103
|
+
is: "2"
|
|
104
|
+
}, em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.START)], [...notify, sound("default")]),
|
|
105
|
+
r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_END_ONE_TO_ONE, [{
|
|
106
|
+
kind: "room_member_count",
|
|
107
|
+
is: "2"
|
|
108
|
+
}, em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.END)], [...notify, sound("default")]),
|
|
109
|
+
r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_START, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.START)], notify),
|
|
110
|
+
r(UNSTABLE.ORG.MATRIX.MSC3930.RULE.POLL_END, [em("type", UNSTABLE.ORG.MATRIX.MSC3381.POLL.END)], notify),
|
|
111
|
+
r(M.RULE.ENCRYPTED, [em("type", M.ROOM.ENCRYPTED)], notify)
|
|
112
|
+
]
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Serialise read-modify-write cycles on a user's push rules (cluster-wide). */
|
|
116
|
+
function withRulesLock(hs, userId, fn) {
|
|
117
|
+
return hs.cluster.lock(`pushrules:${userId}`, () => {
|
|
118
|
+
cacheFor(hs).delete(userId);
|
|
119
|
+
return fn();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const rulesCache = /* @__PURE__ */ new WeakMap();
|
|
123
|
+
function cacheFor(hs) {
|
|
124
|
+
let c = rulesCache.get(hs);
|
|
125
|
+
if (!c) {
|
|
126
|
+
const cache = /* @__PURE__ */ new Map();
|
|
127
|
+
rulesCache.set(hs, c = cache);
|
|
128
|
+
hs.cluster.subscribe("pushrules", (msg) => {
|
|
129
|
+
if (msg?.from !== hs.instanceId) cache.delete(msg.userId);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return c;
|
|
133
|
+
}
|
|
134
|
+
async function loadRules(hs, userId) {
|
|
135
|
+
const cached = cacheFor(hs).get(userId);
|
|
136
|
+
if (cached) return structuredClone(cached);
|
|
137
|
+
const rules = await readRules(hs, userId);
|
|
138
|
+
const cache = cacheFor(hs);
|
|
139
|
+
if (cache.size > 5e3) cache.clear();
|
|
140
|
+
cache.set(userId, rules);
|
|
141
|
+
return structuredClone(rules);
|
|
142
|
+
}
|
|
143
|
+
async function readRules(hs, userId) {
|
|
144
|
+
const row = await hs.db.findOne("account_data", [
|
|
145
|
+
{
|
|
146
|
+
field: "user_id",
|
|
147
|
+
value: userId
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
field: "room_id",
|
|
151
|
+
value: ""
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
field: "type",
|
|
155
|
+
value: M.PUSH_RULES
|
|
156
|
+
}
|
|
157
|
+
]);
|
|
158
|
+
if (!isObject(row?.content?.global)) return defaultRules(userId);
|
|
159
|
+
const rules = row.content.global;
|
|
160
|
+
const defaults = defaultRules(userId);
|
|
161
|
+
for (const kind of KINDS) if (!Array.isArray(rules[kind])) rules[kind] = defaults[kind];
|
|
162
|
+
return rules;
|
|
163
|
+
}
|
|
164
|
+
async function saveRules(hs, userId, rules) {
|
|
165
|
+
cacheFor(hs).set(userId, structuredClone(rules));
|
|
166
|
+
hs.cluster.publish("pushrules", {
|
|
167
|
+
from: hs.instanceId,
|
|
168
|
+
userId
|
|
169
|
+
}).catch(() => {});
|
|
170
|
+
await hs.streams.write((stream) => hs.db.upsert("account_data", {
|
|
171
|
+
user_id: userId,
|
|
172
|
+
room_id: "",
|
|
173
|
+
type: M.PUSH_RULES,
|
|
174
|
+
content: { global: rules },
|
|
175
|
+
stream
|
|
176
|
+
}));
|
|
177
|
+
hs.notifier.notify([userId]);
|
|
178
|
+
}
|
|
179
|
+
/** Copy room-specific push rules from an upgraded room's predecessor to the new room. */
|
|
180
|
+
function copyRoomPushRules(hs, userId, oldRoomId, newRoomId) {
|
|
181
|
+
return withRulesLock(hs, userId, () => copyRules(hs, userId, oldRoomId, newRoomId));
|
|
182
|
+
}
|
|
183
|
+
async function copyRules(hs, userId, oldRoomId, newRoomId) {
|
|
184
|
+
const row = await hs.db.findOne("account_data", [
|
|
185
|
+
{
|
|
186
|
+
field: "user_id",
|
|
187
|
+
value: userId
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
field: "room_id",
|
|
191
|
+
value: ""
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
field: "type",
|
|
195
|
+
value: M.PUSH_RULES
|
|
196
|
+
}
|
|
197
|
+
]);
|
|
198
|
+
if (!isObject(row?.content?.global)) return;
|
|
199
|
+
const rules = row.content.global;
|
|
200
|
+
let changed = false;
|
|
201
|
+
for (const kind of KINDS) {
|
|
202
|
+
const list = rules[kind] ?? [];
|
|
203
|
+
const additions = [];
|
|
204
|
+
for (const rule of list) {
|
|
205
|
+
if (rule.default) continue;
|
|
206
|
+
if (kind === "room" && rule.rule_id === oldRoomId) {
|
|
207
|
+
if (!list.some((r) => r.rule_id === newRoomId)) additions.push({
|
|
208
|
+
...structuredClone(rule),
|
|
209
|
+
rule_id: newRoomId
|
|
210
|
+
});
|
|
211
|
+
} else if (Array.isArray(rule.conditions) && rule.conditions.some((c) => c?.key === "room_id" && c?.pattern === oldRoomId)) {
|
|
212
|
+
const copy = structuredClone(rule);
|
|
213
|
+
copy.rule_id = `${rule.rule_id}-${newRoomId}`;
|
|
214
|
+
copy.conditions = copy.conditions.map((c) => c?.key === "room_id" && c?.pattern === oldRoomId ? {
|
|
215
|
+
...c,
|
|
216
|
+
pattern: newRoomId
|
|
217
|
+
} : c);
|
|
218
|
+
if (!list.some((r) => r.rule_id === copy.rule_id)) additions.push(copy);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (additions.length) {
|
|
222
|
+
rules[kind] = [...additions, ...list];
|
|
223
|
+
changed = true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (changed) await saveRules(hs, userId, rules);
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
export { KINDS, copyRoomPushRules, defaultRules, loadRules, saveRules, withRulesLock };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/core/ratelimit.d.ts
|
|
2
|
+
/** A rate limiter keyed by an arbitrary string. Implement this to share limits across instances (e.g. Redis). */
|
|
3
|
+
export interface RateLimiter {
|
|
4
|
+
/** Consume one unit for `key` under `rule`; resolve with the delay to wait (0 when allowed). */
|
|
5
|
+
consume(key: string, rule: RateLimitRule): Promise<number>;
|
|
6
|
+
}
|
|
7
|
+
export interface RateLimitRule {
|
|
8
|
+
/** Sustained requests per second. */
|
|
9
|
+
perSecond: number;
|
|
10
|
+
/** Burst size. */
|
|
11
|
+
burst: number;
|
|
12
|
+
}
|
|
13
|
+
export type RateLimitAction = "login" | "register" | "message" | "join" | "invite" | "upload" | "createRoom" | "keyQuery";
|
|
14
|
+
/** In-process token bucket limiter. */
|
|
15
|
+
export declare function memoryRateLimiter(): RateLimiter;
|
|
16
|
+
export declare class RateLimits {
|
|
17
|
+
private limiter;
|
|
18
|
+
private rules;
|
|
19
|
+
constructor(limiter: RateLimiter | null, overrides?: Partial<Record<RateLimitAction, RateLimitRule>>);
|
|
20
|
+
/** Throw M_LIMIT_EXCEEDED when `key` exceeded the limit for `action`. Exempt keys are never limited. */
|
|
21
|
+
check(action: RateLimitAction, key: string): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { MatrixError } from "./errors.js";
|
|
2
|
+
//#region src/core/ratelimit.ts
|
|
3
|
+
const DEFAULT_RATE_LIMITS = {
|
|
4
|
+
login: {
|
|
5
|
+
perSecond: .17,
|
|
6
|
+
burst: 5
|
|
7
|
+
},
|
|
8
|
+
register: {
|
|
9
|
+
perSecond: .17,
|
|
10
|
+
burst: 3
|
|
11
|
+
},
|
|
12
|
+
message: {
|
|
13
|
+
perSecond: 1,
|
|
14
|
+
burst: 20
|
|
15
|
+
},
|
|
16
|
+
join: {
|
|
17
|
+
perSecond: .5,
|
|
18
|
+
burst: 10
|
|
19
|
+
},
|
|
20
|
+
invite: {
|
|
21
|
+
perSecond: .3,
|
|
22
|
+
burst: 10
|
|
23
|
+
},
|
|
24
|
+
upload: {
|
|
25
|
+
perSecond: 1,
|
|
26
|
+
burst: 10
|
|
27
|
+
},
|
|
28
|
+
createRoom: {
|
|
29
|
+
perSecond: .1,
|
|
30
|
+
burst: 5
|
|
31
|
+
},
|
|
32
|
+
keyQuery: {
|
|
33
|
+
perSecond: 5,
|
|
34
|
+
burst: 50
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** In-process token bucket limiter. */
|
|
38
|
+
function memoryRateLimiter() {
|
|
39
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
40
|
+
return { async consume(key, rule) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
let b = buckets.get(key);
|
|
43
|
+
if (!b) {
|
|
44
|
+
if (buckets.size > 1e5) buckets.clear();
|
|
45
|
+
buckets.set(key, b = {
|
|
46
|
+
tokens: rule.burst,
|
|
47
|
+
updated: now
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
b.tokens = Math.min(rule.burst, b.tokens + (now - b.updated) / 1e3 * rule.perSecond);
|
|
51
|
+
b.updated = now;
|
|
52
|
+
if (b.tokens >= 1) {
|
|
53
|
+
b.tokens -= 1;
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
return Math.ceil((1 - b.tokens) / rule.perSecond * 1e3);
|
|
57
|
+
} };
|
|
58
|
+
}
|
|
59
|
+
var RateLimits = class {
|
|
60
|
+
limiter;
|
|
61
|
+
rules;
|
|
62
|
+
constructor(limiter, overrides = {}) {
|
|
63
|
+
this.limiter = limiter;
|
|
64
|
+
this.rules = {
|
|
65
|
+
...DEFAULT_RATE_LIMITS,
|
|
66
|
+
...overrides
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Throw M_LIMIT_EXCEEDED when `key` exceeded the limit for `action`. Exempt keys are never limited. */
|
|
70
|
+
async check(action, key) {
|
|
71
|
+
if (!this.limiter) return;
|
|
72
|
+
const wait = await this.limiter.consume(`${action}:${key}`, this.rules[action]);
|
|
73
|
+
if (wait > 0) throw new MatrixError(429, "M_LIMIT_EXCEEDED", "Too many requests", { retry_after_ms: wait });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { DEFAULT_RATE_LIMITS, RateLimits, memoryRateLimiter };
|