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
package/dist/api/sync.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { M, UNSTABLE } from "../core/m.js";
|
|
2
|
+
import { eventIdOf, splitStateKey, stateKey, toClientEvent } from "../core/events.js";
|
|
3
|
+
import { errors } from "../core/errors.js";
|
|
4
|
+
import { getState } from "../core/authrules.js";
|
|
5
|
+
import { loadRules } from "../core/pushrules.js";
|
|
6
|
+
import { inviteDecision } from "../core/invitefilter.js";
|
|
7
|
+
import { body } from "../http/input.js";
|
|
8
|
+
import { procedure } from "../http/procedure.js";
|
|
9
|
+
import { matchesFilter, roomAllowed, validateSyncFilter } from "./filters.js";
|
|
10
|
+
import { fullProfile } from "../core/profile.js";
|
|
11
|
+
//#region src/api/sync.ts
|
|
12
|
+
function parseSyncToken(raw) {
|
|
13
|
+
if (!raw) return null;
|
|
14
|
+
const m = /^s(\d+)(?:_(\d+))?$/.exec(raw);
|
|
15
|
+
if (!m) throw errors.invalidParam("Invalid since token");
|
|
16
|
+
return {
|
|
17
|
+
pos: Number(m[1]),
|
|
18
|
+
toDevice: Number(m[2] ?? m[1])
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const formatToken = (t) => `s${t.pos}_${t.toDevice}`;
|
|
22
|
+
async function loadFilter(hs, userId, raw) {
|
|
23
|
+
if (!raw) return {};
|
|
24
|
+
if (raw.trim().startsWith("{")) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(raw);
|
|
28
|
+
} catch {
|
|
29
|
+
throw errors.badJson("Invalid filter JSON");
|
|
30
|
+
}
|
|
31
|
+
return validateSyncFilter(parsed);
|
|
32
|
+
}
|
|
33
|
+
const row = await hs.db.findOne("filters", [{
|
|
34
|
+
field: "user_id",
|
|
35
|
+
value: userId
|
|
36
|
+
}, {
|
|
37
|
+
field: "filter_id",
|
|
38
|
+
value: raw
|
|
39
|
+
}]);
|
|
40
|
+
if (!row) throw errors.notFound("Unknown filter");
|
|
41
|
+
return row.filter;
|
|
42
|
+
}
|
|
43
|
+
/** A stripped state event as clients expect it. */
|
|
44
|
+
function toStripped(ev) {
|
|
45
|
+
return {
|
|
46
|
+
type: ev.type,
|
|
47
|
+
state_key: ev.state_key,
|
|
48
|
+
sender: ev.sender,
|
|
49
|
+
content: ev.content
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** State snapshot ID in effect at stream position `pos` in a room (0 = empty). */
|
|
53
|
+
async function snapshotAt(hs, roomId, pos) {
|
|
54
|
+
return (await hs.db.findMany("events", {
|
|
55
|
+
where: [{
|
|
56
|
+
field: "room_id",
|
|
57
|
+
value: roomId
|
|
58
|
+
}, {
|
|
59
|
+
field: "stream",
|
|
60
|
+
operator: "lte",
|
|
61
|
+
value: pos
|
|
62
|
+
}],
|
|
63
|
+
sortBy: {
|
|
64
|
+
field: "stream",
|
|
65
|
+
direction: "desc"
|
|
66
|
+
},
|
|
67
|
+
limit: 1
|
|
68
|
+
}))[0]?.state_snapshot ?? 0;
|
|
69
|
+
}
|
|
70
|
+
/** Build the timeline + state section of a room. */
|
|
71
|
+
async function roomTimeline(hs, p, roomId, since, upTo) {
|
|
72
|
+
const tf = p.filter.room?.timeline;
|
|
73
|
+
const limit = tf?.limit ?? 10;
|
|
74
|
+
const where = [{
|
|
75
|
+
field: "room_id",
|
|
76
|
+
value: roomId
|
|
77
|
+
}, {
|
|
78
|
+
field: "stream",
|
|
79
|
+
operator: "lte",
|
|
80
|
+
value: upTo
|
|
81
|
+
}];
|
|
82
|
+
if (since !== null) where.push({
|
|
83
|
+
field: "stream",
|
|
84
|
+
operator: "gt",
|
|
85
|
+
value: since
|
|
86
|
+
});
|
|
87
|
+
const events = [];
|
|
88
|
+
let limited = false;
|
|
89
|
+
let cursor = upTo;
|
|
90
|
+
const batchSize = Math.max(limit * 2, 20);
|
|
91
|
+
for (;;) {
|
|
92
|
+
const w = where.map((c) => c.field === "stream" && c.operator === "lte" ? {
|
|
93
|
+
...c,
|
|
94
|
+
value: cursor
|
|
95
|
+
} : c);
|
|
96
|
+
const rows = await hs.db.findMany("events", {
|
|
97
|
+
where: w,
|
|
98
|
+
sortBy: {
|
|
99
|
+
field: "stream",
|
|
100
|
+
direction: "desc"
|
|
101
|
+
},
|
|
102
|
+
limit: batchSize
|
|
103
|
+
});
|
|
104
|
+
if (rows.length === 0) break;
|
|
105
|
+
const stored = rows.map((r) => hs.rooms.rowToStored(r));
|
|
106
|
+
const visible = await hs.rooms.filterVisible(p.session.userId, roomId, stored);
|
|
107
|
+
const ok = new Map(visible.map((v) => [v.pdu.event_id, v]));
|
|
108
|
+
let full = false;
|
|
109
|
+
for (const raw of stored) {
|
|
110
|
+
cursor = raw.stream - 1;
|
|
111
|
+
const ev = ok.get(raw.pdu.event_id);
|
|
112
|
+
if (!ev || !matchesFilter(tf, ev.pdu) || p.ignored.has(ev.pdu.sender)) continue;
|
|
113
|
+
if (events.length >= (since === null ? limit * 2 : limit)) {
|
|
114
|
+
limited = true;
|
|
115
|
+
full = true;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
events.push(ev);
|
|
119
|
+
}
|
|
120
|
+
if (full || rows.length < batchSize) break;
|
|
121
|
+
}
|
|
122
|
+
if (since === null && events.length > limit) {
|
|
123
|
+
events.sort((a, b) => (b.pdu.depth ?? 0) - (a.pdu.depth ?? 0) || b.stream - a.stream);
|
|
124
|
+
events.length = limit;
|
|
125
|
+
limited = true;
|
|
126
|
+
}
|
|
127
|
+
if (limit === 0 && events.length === 0) limited = (await hs.db.findMany("events", {
|
|
128
|
+
where,
|
|
129
|
+
limit: 1
|
|
130
|
+
})).length > 0;
|
|
131
|
+
if (since === null) events.sort((a, b) => (a.pdu.depth ?? 0) - (b.pdu.depth ?? 0) || a.stream - b.stream);
|
|
132
|
+
else events.reverse();
|
|
133
|
+
let lastGap = -1;
|
|
134
|
+
events.forEach((e, i) => {
|
|
135
|
+
if (e.gap) lastGap = i;
|
|
136
|
+
});
|
|
137
|
+
if (lastGap >= 0) {
|
|
138
|
+
events.splice(0, lastGap);
|
|
139
|
+
limited = true;
|
|
140
|
+
}
|
|
141
|
+
if (!limited && events.length) limited = (await hs.db.findMany("events", {
|
|
142
|
+
where: [...where.filter((c) => c.operator !== "lte"), {
|
|
143
|
+
field: "stream",
|
|
144
|
+
operator: "lt",
|
|
145
|
+
value: events[0].stream
|
|
146
|
+
}],
|
|
147
|
+
limit: 1
|
|
148
|
+
})).length > 0;
|
|
149
|
+
const startPos = events.length ? Math.min(...events.map((e) => e.stream)) - 1 : upTo;
|
|
150
|
+
const startState = await hs.rooms.stateAtSnapshot(await snapshotAt(hs, roomId, startPos));
|
|
151
|
+
const timelineIds = new Set(events.map((e) => e.pdu.event_id));
|
|
152
|
+
const endState = await hs.rooms.stateAtSnapshot(await snapshotAt(hs, roomId, upTo));
|
|
153
|
+
for (const [key, ev] of endState) {
|
|
154
|
+
if (timelineIds.has(ev.event_id) || startState.get(key)?.event_id === ev.event_id) continue;
|
|
155
|
+
if (events.some((e) => e.pdu.state_key !== void 0 && stateKey(e.pdu.type, e.pdu.state_key) === key)) continue;
|
|
156
|
+
startState.set(key, ev);
|
|
157
|
+
}
|
|
158
|
+
const sinceIds = since !== null ? await hs.rooms.snapshotIds(await snapshotAt(hs, roomId, since)) : {};
|
|
159
|
+
const sf = p.filter.room?.state;
|
|
160
|
+
const lazy = !!sf?.lazy_load_members;
|
|
161
|
+
const senders = new Set(events.map((e) => e.pdu.sender));
|
|
162
|
+
senders.add(p.session.userId);
|
|
163
|
+
const state = [];
|
|
164
|
+
for (const [key, ev] of startState) {
|
|
165
|
+
if (sinceIds[key] === ev.event_id) continue;
|
|
166
|
+
const [type, sk] = splitStateKey(key);
|
|
167
|
+
if (!matchesFilter(sf, ev)) continue;
|
|
168
|
+
if (lazy && type === M.ROOM.MEMBER && !senders.has(sk)) continue;
|
|
169
|
+
state.push(ev);
|
|
170
|
+
}
|
|
171
|
+
if (lazy) for (const e of events) {
|
|
172
|
+
const key = stateKey(M.ROOM.MEMBER, e.pdu.sender);
|
|
173
|
+
if (startState.has(key) || state.some((s) => s.type === M.ROOM.MEMBER && s.state_key === e.pdu.sender)) continue;
|
|
174
|
+
const member = [...(await hs.rooms.getPdus((e.pdu.auth_events ?? []).map(eventIdOf))).values()].find((a) => a.type === M.ROOM.MEMBER && a.state_key === e.pdu.sender);
|
|
175
|
+
if (member && matchesFilter(sf, member)) state.push(member);
|
|
176
|
+
}
|
|
177
|
+
const stateAfter = [];
|
|
178
|
+
if (p.useStateAfter) {
|
|
179
|
+
const endState = await hs.rooms.stateAtSnapshot(await snapshotAt(hs, roomId, upTo));
|
|
180
|
+
for (const [key, ev] of endState) {
|
|
181
|
+
if (sinceIds[key] === ev.event_id) continue;
|
|
182
|
+
const [type, sk] = splitStateKey(key);
|
|
183
|
+
if (!matchesFilter(sf, ev)) continue;
|
|
184
|
+
if (lazy && type === M.ROOM.MEMBER && !senders.has(sk)) continue;
|
|
185
|
+
stateAfter.push(ev);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
stateAfter,
|
|
190
|
+
timeline: {
|
|
191
|
+
events: events.map((e) => hs.rooms.formatEvent(e, p.session, { includeRoomId: false })),
|
|
192
|
+
limited,
|
|
193
|
+
prev_batch: `s${startPos}`
|
|
194
|
+
},
|
|
195
|
+
state,
|
|
196
|
+
timelineEvents: events
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function roomEphemeral(hs, p, roomId) {
|
|
200
|
+
const since = p.since?.pos ?? null;
|
|
201
|
+
const out = [];
|
|
202
|
+
const typing = hs.typing.typingSince(roomId, since);
|
|
203
|
+
if (typing !== null && (since !== null || typing.length > 0)) out.push({
|
|
204
|
+
type: M.TYPING,
|
|
205
|
+
content: { user_ids: typing }
|
|
206
|
+
});
|
|
207
|
+
const where = [{
|
|
208
|
+
field: "room_id",
|
|
209
|
+
value: roomId
|
|
210
|
+
}];
|
|
211
|
+
if (since !== null) where.push({
|
|
212
|
+
field: "stream",
|
|
213
|
+
operator: "gt",
|
|
214
|
+
value: since
|
|
215
|
+
});
|
|
216
|
+
const receipts = (await hs.db.findMany("receipts", { where })).filter((r) => r.stream <= p.to);
|
|
217
|
+
const content = {};
|
|
218
|
+
for (const r of receipts) {
|
|
219
|
+
if (r.receipt_type === M.READ.PRIVATE && r.user_id !== p.session.userId) continue;
|
|
220
|
+
content[r.event_id] ??= {};
|
|
221
|
+
content[r.event_id][r.receipt_type] ??= {};
|
|
222
|
+
if (r.thread_id && content[r.event_id][r.receipt_type][r.user_id] && !content[r.event_id][r.receipt_type][r.user_id].thread_id) continue;
|
|
223
|
+
content[r.event_id][r.receipt_type][r.user_id] = {
|
|
224
|
+
ts: r.ts,
|
|
225
|
+
...r.thread_id ? { thread_id: r.thread_id } : {}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (Object.keys(content).length) out.push({
|
|
229
|
+
type: M.RECEIPT,
|
|
230
|
+
content
|
|
231
|
+
});
|
|
232
|
+
return out.filter((e) => matchesFilter(p.filter.room?.ephemeral, e));
|
|
233
|
+
}
|
|
234
|
+
async function accountData(hs, p, roomId) {
|
|
235
|
+
const where = [{
|
|
236
|
+
field: "user_id",
|
|
237
|
+
value: p.session.userId
|
|
238
|
+
}, {
|
|
239
|
+
field: "room_id",
|
|
240
|
+
value: roomId
|
|
241
|
+
}];
|
|
242
|
+
if (p.since && !p.fullState) where.push({
|
|
243
|
+
field: "stream",
|
|
244
|
+
operator: "gt",
|
|
245
|
+
value: p.since.pos
|
|
246
|
+
});
|
|
247
|
+
const rows = await hs.db.findMany("account_data", { where });
|
|
248
|
+
const filter = roomId ? p.filter.room?.account_data : p.filter.account_data;
|
|
249
|
+
return rows.filter((r) => r.stream <= p.to && (p.since || Object.keys(r.content ?? {}).length > 0)).map((r) => ({
|
|
250
|
+
type: r.type,
|
|
251
|
+
content: r.content
|
|
252
|
+
})).filter((e) => matchesFilter(filter, e));
|
|
253
|
+
}
|
|
254
|
+
async function roomSummary(hs, roomId, userId) {
|
|
255
|
+
const state = await hs.rooms.currentState(roomId);
|
|
256
|
+
let joined = 0;
|
|
257
|
+
let invited = 0;
|
|
258
|
+
const heroes = [];
|
|
259
|
+
for (const [key, ev] of state) {
|
|
260
|
+
const [type, sk] = splitStateKey(key);
|
|
261
|
+
if (type !== M.ROOM.MEMBER) continue;
|
|
262
|
+
const m = ev.content?.membership;
|
|
263
|
+
if (m === "join") joined++;
|
|
264
|
+
if (m === "invite") invited++;
|
|
265
|
+
if ((m === "join" || m === "invite") && sk !== userId && heroes.length < 5) heroes.push(sk);
|
|
266
|
+
}
|
|
267
|
+
const summary = {
|
|
268
|
+
[M.JOINED_MEMBER_COUNT]: joined,
|
|
269
|
+
[M.INVITED_MEMBER_COUNT]: invited
|
|
270
|
+
};
|
|
271
|
+
if (!getState(state, M.ROOM.NAME)?.content?.name && !getState(state, M.ROOM.CANONICAL_ALIAS)?.content?.alias) summary[M.HEROES] = heroes.sort();
|
|
272
|
+
return summary;
|
|
273
|
+
}
|
|
274
|
+
async function buildSync(hs, p) {
|
|
275
|
+
const userId = p.session.userId;
|
|
276
|
+
const since = p.since?.pos ?? null;
|
|
277
|
+
const memberships = await hs.db.findMany("memberships", { where: [{
|
|
278
|
+
field: "user_id",
|
|
279
|
+
value: userId
|
|
280
|
+
}] });
|
|
281
|
+
const join = {};
|
|
282
|
+
const invite = {};
|
|
283
|
+
const leave = {};
|
|
284
|
+
const knock = {};
|
|
285
|
+
let empty = true;
|
|
286
|
+
for (const m of memberships) {
|
|
287
|
+
if (m.stream > p.to || !roomAllowed(p.filter, m.room_id)) continue;
|
|
288
|
+
const roomId = m.room_id;
|
|
289
|
+
const changed = since === null || m.stream > since;
|
|
290
|
+
if (m.membership === "join") {
|
|
291
|
+
if (!p.filter.room?.state?.lazy_load_members && await hs.partialState.isPartial(roomId)) continue;
|
|
292
|
+
const completedAt = await hs.partialState.completedAt(roomId);
|
|
293
|
+
const initial = since === null || p.fullState || m.stream > since || completedAt !== null && completedAt > since && completedAt <= p.to;
|
|
294
|
+
const { timeline, state, stateAfter, timelineEvents } = await roomTimeline(hs, p, roomId, initial ? null : since, p.to);
|
|
295
|
+
const ephemeral = await roomEphemeral(hs, p, roomId);
|
|
296
|
+
const roomAccountData = await accountData(hs, p, roomId);
|
|
297
|
+
if (!initial && timelineEvents.length === 0 && state.length === 0 && stateAfter.length === 0 && ephemeral.length === 0 && roomAccountData.length === 0) continue;
|
|
298
|
+
join[roomId] = {
|
|
299
|
+
timeline,
|
|
300
|
+
...p.useStateAfter ? { [p.stateAfterKey ?? "state_after"]: { events: stateAfter.map((e) => toClientEvent(e, { includeRoomId: false })) } } : { state: { events: state.map((e) => toClientEvent(e, { includeRoomId: false })) } },
|
|
301
|
+
ephemeral: { events: ephemeral },
|
|
302
|
+
account_data: { events: roomAccountData },
|
|
303
|
+
summary: await roomSummary(hs, roomId, userId)
|
|
304
|
+
};
|
|
305
|
+
const counts = await hs.push.unreadCounts(userId, roomId);
|
|
306
|
+
if (p.filter.room?.timeline?.unread_thread_notifications) {
|
|
307
|
+
join[roomId].unread_notifications = counts.room;
|
|
308
|
+
if (Object.keys(counts.threads).length) join[roomId].unread_thread_notifications = counts.threads;
|
|
309
|
+
} else {
|
|
310
|
+
const total = { ...counts.room };
|
|
311
|
+
for (const t of Object.values(counts.threads)) {
|
|
312
|
+
total.notification_count += t.notification_count;
|
|
313
|
+
total.highlight_count += t.highlight_count;
|
|
314
|
+
}
|
|
315
|
+
join[roomId].unread_notifications = total;
|
|
316
|
+
}
|
|
317
|
+
empty = false;
|
|
318
|
+
} else if (m.membership === "invite") {
|
|
319
|
+
if (!changed && !p.fullState) continue;
|
|
320
|
+
const pdu = await hs.rooms.getPdu(m.event_id);
|
|
321
|
+
if (!pdu || p.ignored.has(pdu.sender)) continue;
|
|
322
|
+
if (await inviteDecision(hs, userId, pdu.sender) !== "allow") continue;
|
|
323
|
+
invite[roomId] = { invite_state: { events: [...pdu.unsigned?.invite_room_state ?? [], pdu].map(toStripped) } };
|
|
324
|
+
empty = false;
|
|
325
|
+
} else if (m.membership === "knock") {
|
|
326
|
+
if (!changed && !p.fullState) continue;
|
|
327
|
+
const knockPdu = await hs.rooms.getPdu(m.event_id);
|
|
328
|
+
const ev = knockPdu ? { pdu: knockPdu } : null;
|
|
329
|
+
if (!ev) continue;
|
|
330
|
+
knock[roomId] = { knock_state: { events: [...ev.pdu.unsigned?.knock_room_state ?? [], ev.pdu].map(toStripped) } };
|
|
331
|
+
empty = false;
|
|
332
|
+
} else {
|
|
333
|
+
if (m.forgotten && since === null) continue;
|
|
334
|
+
if (since === null ? !p.filter.room?.include_leave : m.stream <= since) continue;
|
|
335
|
+
const { timeline, state } = await roomTimeline(hs, p, roomId, since, m.stream);
|
|
336
|
+
const tf = p.filter.room?.timeline;
|
|
337
|
+
if (!await hs.rooms.exists(roomId) && (tf?.limit ?? 10) > 0 && !timeline.events.some((e) => e.event_id === m.event_id)) {
|
|
338
|
+
const leavePdu = await hs.rooms.getPdu(m.event_id);
|
|
339
|
+
if (leavePdu && matchesFilter(tf, leavePdu)) timeline.events.push(toClientEvent(leavePdu, { includeRoomId: false }));
|
|
340
|
+
}
|
|
341
|
+
leave[roomId] = {
|
|
342
|
+
timeline,
|
|
343
|
+
state: { events: state.map((e) => toClientEvent(e, { includeRoomId: false })) },
|
|
344
|
+
account_data: { events: await accountData(hs, p, roomId) }
|
|
345
|
+
};
|
|
346
|
+
empty = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const globalAccountData = await accountData(hs, p, "");
|
|
350
|
+
if (since === null && !globalAccountData.some((e) => e.type === M.PUSH_RULES)) globalAccountData.push({
|
|
351
|
+
type: M.PUSH_RULES,
|
|
352
|
+
content: { global: await loadRules(hs, userId) }
|
|
353
|
+
});
|
|
354
|
+
if (globalAccountData.length) empty = false;
|
|
355
|
+
const deviceWhere = [{
|
|
356
|
+
field: "user_id",
|
|
357
|
+
value: userId
|
|
358
|
+
}, {
|
|
359
|
+
field: "device_id",
|
|
360
|
+
value: p.session.deviceId
|
|
361
|
+
}];
|
|
362
|
+
if (p.since) await hs.db.delete("to_device", [...deviceWhere, {
|
|
363
|
+
field: "stream",
|
|
364
|
+
operator: "lte",
|
|
365
|
+
value: p.since.toDevice
|
|
366
|
+
}]);
|
|
367
|
+
const toDevice = (await hs.db.findMany("to_device", {
|
|
368
|
+
where: [...deviceWhere, {
|
|
369
|
+
field: "stream",
|
|
370
|
+
operator: "gt",
|
|
371
|
+
value: p.since?.toDevice ?? 0
|
|
372
|
+
}],
|
|
373
|
+
sortBy: {
|
|
374
|
+
field: "stream",
|
|
375
|
+
direction: "asc"
|
|
376
|
+
},
|
|
377
|
+
limit: 100
|
|
378
|
+
})).filter((r) => r.stream <= p.to);
|
|
379
|
+
const nextToDevice = toDevice.length ? toDevice[toDevice.length - 1].stream : p.since?.toDevice ?? 0;
|
|
380
|
+
if (toDevice.length) empty = false;
|
|
381
|
+
const presenceEvents = [];
|
|
382
|
+
if (!p.filter.presence?.types || p.filter.presence.types.length) {
|
|
383
|
+
const sharing = await hs.rooms.usersSharingRooms(userId);
|
|
384
|
+
const newlyShared = /* @__PURE__ */ new Set();
|
|
385
|
+
if (since !== null) {
|
|
386
|
+
const changes = await hs.db.findMany("memberships", { where: [{
|
|
387
|
+
field: "stream",
|
|
388
|
+
operator: "gt",
|
|
389
|
+
value: since
|
|
390
|
+
}] });
|
|
391
|
+
const myRooms = new Set(memberships.filter((m) => m.membership === "join").map((m) => m.room_id));
|
|
392
|
+
for (const c of changes) {
|
|
393
|
+
if (c.membership !== "join" || !myRooms.has(c.room_id)) continue;
|
|
394
|
+
if (c.user_id === userId) for (const u of await hs.rooms.joinedMembers(c.room_id)) newlyShared.add(u);
|
|
395
|
+
else newlyShared.add(c.user_id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const where = [{
|
|
399
|
+
field: "user_id",
|
|
400
|
+
operator: "in",
|
|
401
|
+
value: [...sharing]
|
|
402
|
+
}];
|
|
403
|
+
for (const row of await hs.db.findMany("presence", { where })) {
|
|
404
|
+
if (row.stream > p.to) continue;
|
|
405
|
+
if (since !== null && row.stream <= since && !newlyShared.has(row.user_id)) continue;
|
|
406
|
+
const content = {
|
|
407
|
+
presence: row.presence,
|
|
408
|
+
last_active_ago: Date.now() - row.last_active_ts
|
|
409
|
+
};
|
|
410
|
+
if (row.status_msg) content.status_msg = row.status_msg;
|
|
411
|
+
if (row.presence === "online") content.currently_active = true;
|
|
412
|
+
const ev = {
|
|
413
|
+
type: M.PRESENCE,
|
|
414
|
+
sender: row.user_id,
|
|
415
|
+
content
|
|
416
|
+
};
|
|
417
|
+
if (matchesFilter(p.filter.presence, ev)) presenceEvents.push(ev);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (presenceEvents.length) empty = false;
|
|
421
|
+
const profileUpdates = await profileUpdatesFor(hs, p, memberships);
|
|
422
|
+
if (profileUpdates) empty = false;
|
|
423
|
+
const body = {
|
|
424
|
+
next_batch: formatToken({
|
|
425
|
+
pos: p.to,
|
|
426
|
+
toDevice: nextToDevice
|
|
427
|
+
}),
|
|
428
|
+
rooms: Object.fromEntries(Object.entries({
|
|
429
|
+
join,
|
|
430
|
+
invite,
|
|
431
|
+
leave,
|
|
432
|
+
knock
|
|
433
|
+
}).filter(([, v]) => Object.keys(v).length)),
|
|
434
|
+
presence: { events: presenceEvents },
|
|
435
|
+
account_data: { events: globalAccountData },
|
|
436
|
+
to_device: { events: toDevice.map((r) => ({
|
|
437
|
+
sender: r.sender,
|
|
438
|
+
type: r.type,
|
|
439
|
+
content: r.content
|
|
440
|
+
})) },
|
|
441
|
+
device_one_time_keys_count: await hs.keys.oneTimeKeyCounts(userId, p.session.deviceId),
|
|
442
|
+
device_unused_fallback_key_types: await hs.keys.unusedFallbackKeyTypes(userId, p.session.deviceId)
|
|
443
|
+
};
|
|
444
|
+
if (profileUpdates) body[UNSTABLE.ORG.MATRIX.MSC4429.USERS] = profileUpdates;
|
|
445
|
+
if (since !== null) {
|
|
446
|
+
const lists = await hs.keys.changesSince(userId, since, p.to);
|
|
447
|
+
body.device_lists = lists;
|
|
448
|
+
if (lists.changed.length || lists.left.length) empty = false;
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
body,
|
|
452
|
+
empty,
|
|
453
|
+
nextToDevice
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async function sync(ctx) {
|
|
457
|
+
const hs = ctx.hs;
|
|
458
|
+
const session = await ctx.auth();
|
|
459
|
+
const filter = await loadFilter(hs, session.userId, ctx.query.get("filter"));
|
|
460
|
+
const since = parseSyncToken(ctx.query.get("since"));
|
|
461
|
+
const fullState = ctx.query.get("full_state") === "true";
|
|
462
|
+
const timeout = Math.min(Math.max(Number.parseInt(ctx.query.get("timeout") ?? "0", 10) || 0, 0), hs.config.maxSyncTimeoutMs);
|
|
463
|
+
const setPresence = ctx.query.get("set_presence") ?? "online";
|
|
464
|
+
if (setPresence !== "offline") await touchPresence(hs, session.userId, setPresence);
|
|
465
|
+
const deadline = Date.now() + timeout;
|
|
466
|
+
for (;;) {
|
|
467
|
+
const to = await hs.streams.position();
|
|
468
|
+
const ignoredRow = await hs.db.findOne("account_data", [
|
|
469
|
+
{
|
|
470
|
+
field: "user_id",
|
|
471
|
+
value: session.userId
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
field: "room_id",
|
|
475
|
+
value: ""
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
field: "type",
|
|
479
|
+
value: M.IGNORED_USER_LIST
|
|
480
|
+
}
|
|
481
|
+
]);
|
|
482
|
+
const ignored = new Set(Object.keys(ignoredRow?.content?.ignored_users ?? {}));
|
|
483
|
+
const stable = ctx.query.get("use_state_after") === "true";
|
|
484
|
+
const unstable = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4222.USE_STATE_AFTER) === "true";
|
|
485
|
+
const { body, empty } = await buildSync(hs, {
|
|
486
|
+
session,
|
|
487
|
+
ignored,
|
|
488
|
+
since,
|
|
489
|
+
to,
|
|
490
|
+
filter,
|
|
491
|
+
fullState,
|
|
492
|
+
useStateAfter: stable || unstable,
|
|
493
|
+
stateAfterKey: stable ? "state_after" : UNSTABLE.ORG.MATRIX.MSC4222.STATE_AFTER
|
|
494
|
+
});
|
|
495
|
+
const remaining = deadline - Date.now();
|
|
496
|
+
if (!since || !empty || remaining <= 0 || fullState) return body;
|
|
497
|
+
if (await hs.streams.position() > to) continue;
|
|
498
|
+
await hs.notifier.wait(session.userId, remaining, ctx.request.signal);
|
|
499
|
+
if (ctx.request.signal?.aborted) return body;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function touchPresence(hs, userId, presence) {
|
|
503
|
+
const state = presence === "unavailable" ? "unavailable" : "online";
|
|
504
|
+
const row = await hs.db.findOne("presence", [{
|
|
505
|
+
field: "user_id",
|
|
506
|
+
value: userId
|
|
507
|
+
}]);
|
|
508
|
+
if (row && row.presence === state) {
|
|
509
|
+
await hs.db.update("presence", [{
|
|
510
|
+
field: "user_id",
|
|
511
|
+
value: userId
|
|
512
|
+
}], { last_active_ts: Date.now() });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await hs.streams.write((stream) => hs.db.upsert("presence", {
|
|
516
|
+
user_id: userId,
|
|
517
|
+
presence: state,
|
|
518
|
+
status_msg: row?.status_msg ?? null,
|
|
519
|
+
last_active_ts: Date.now(),
|
|
520
|
+
stream
|
|
521
|
+
}));
|
|
522
|
+
await hs.federation.onPresence(userId, state, row?.status_msg ?? null).catch(() => {});
|
|
523
|
+
}
|
|
524
|
+
function registerSyncRoutes(r) {
|
|
525
|
+
r.client("GET", "/sync", procedure({ handler: sync }));
|
|
526
|
+
r.client("POST", "/user/:userId/filter", procedure({
|
|
527
|
+
auth: "user",
|
|
528
|
+
input: body({}),
|
|
529
|
+
handler: async (ctx) => {
|
|
530
|
+
const s = ctx.session;
|
|
531
|
+
if (ctx.params.userId !== s.userId) throw errors.forbidden("Cannot create filters for other users");
|
|
532
|
+
const filter = validateSyncFilter(ctx.input);
|
|
533
|
+
const existing = await ctx.hs.db.findMany("filters", { where: [{
|
|
534
|
+
field: "user_id",
|
|
535
|
+
value: s.userId
|
|
536
|
+
}] });
|
|
537
|
+
const filterId = String(existing.reduce((max, f) => Math.max(max, Number(f.filter_id) || 0), 0) + 1);
|
|
538
|
+
await ctx.hs.db.create("filters", {
|
|
539
|
+
user_id: s.userId,
|
|
540
|
+
filter_id: filterId,
|
|
541
|
+
filter
|
|
542
|
+
});
|
|
543
|
+
return { filter_id: filterId };
|
|
544
|
+
}
|
|
545
|
+
}));
|
|
546
|
+
r.client("GET", "/user/:userId/filter/:filterId", procedure({
|
|
547
|
+
auth: "user",
|
|
548
|
+
handler: async (ctx) => {
|
|
549
|
+
const s = ctx.session;
|
|
550
|
+
if (ctx.params.userId !== s.userId) throw errors.forbidden("Cannot read filters of other users");
|
|
551
|
+
const row = await ctx.hs.db.findOne("filters", [{
|
|
552
|
+
field: "user_id",
|
|
553
|
+
value: s.userId
|
|
554
|
+
}, {
|
|
555
|
+
field: "filter_id",
|
|
556
|
+
value: ctx.params.filterId
|
|
557
|
+
}]);
|
|
558
|
+
if (!row) throw errors.notFound("Unknown filter");
|
|
559
|
+
return row.filter;
|
|
560
|
+
}
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* MSC4429: profile changes of users sharing a room, limited to the fields the filter asks for.
|
|
565
|
+
* The initial sync carries current values; users we no longer share any room with get `null`.
|
|
566
|
+
*/
|
|
567
|
+
async function profileUpdatesFor(hs, p, memberships) {
|
|
568
|
+
const ids = (p.filter.profile_fields ?? p.filter[UNSTABLE.ORG.MATRIX.MSC4429.PROFILE_FIELDS])?.ids;
|
|
569
|
+
if (!Array.isArray(ids) || !ids.length) return null;
|
|
570
|
+
const wanted = new Set(ids.filter((x) => typeof x === "string"));
|
|
571
|
+
const since = p.since?.pos ?? null;
|
|
572
|
+
const sharing = await hs.rooms.usersSharingRooms(p.session.userId);
|
|
573
|
+
const out = {};
|
|
574
|
+
const add = (userId, field, value) => {
|
|
575
|
+
(out[userId] ??= { profile_updates: {} }).profile_updates[field] = value ?? null;
|
|
576
|
+
};
|
|
577
|
+
const full = new Set(since === null ? sharing : []);
|
|
578
|
+
if (since !== null) {
|
|
579
|
+
const myRooms = new Set(memberships.filter((m) => m.membership === "join").map((m) => m.room_id));
|
|
580
|
+
const changes = await hs.db.findMany("memberships", { where: [{
|
|
581
|
+
field: "stream",
|
|
582
|
+
operator: "gt",
|
|
583
|
+
value: since
|
|
584
|
+
}] });
|
|
585
|
+
for (const c of changes) {
|
|
586
|
+
if (c.stream > p.to || c.user_id === p.session.userId) continue;
|
|
587
|
+
if (c.membership === "join" && myRooms.has(c.room_id)) {
|
|
588
|
+
if ((await hs.rooms.getEvent(c.event_id))?.pdu.unsigned?.prev_content?.membership !== "join") full.add(c.user_id);
|
|
589
|
+
} else if (!sharing.has(c.user_id) && (myRooms.has(c.room_id) || memberships.some((m) => m.room_id === c.room_id))) out[c.user_id] = { profile_updates: null };
|
|
590
|
+
}
|
|
591
|
+
const rows = await hs.db.findMany("profile_fields", { where: [{
|
|
592
|
+
field: "stream",
|
|
593
|
+
operator: "gt",
|
|
594
|
+
value: since
|
|
595
|
+
}] });
|
|
596
|
+
for (const row of rows) {
|
|
597
|
+
if (row.stream > p.to || !wanted.has(row.field) || !sharing.has(row.user_id) || full.has(row.user_id)) continue;
|
|
598
|
+
add(row.user_id, row.field, row.value);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const userId of full) {
|
|
602
|
+
if (!sharing.has(userId) || !hs.isLocalUser(userId)) continue;
|
|
603
|
+
const profile = await fullProfile(hs, userId) ?? {};
|
|
604
|
+
for (const field of wanted) if (profile[field] !== void 0) add(userId, field, profile[field]);
|
|
605
|
+
}
|
|
606
|
+
return Object.keys(out).length ? out : null;
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
export { registerSyncRoutes, roomEphemeral, roomSummary, roomTimeline, toStripped };
|