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,1487 @@
|
|
|
1
|
+
import { isObject } from "../core/json.js";
|
|
2
|
+
import { M, UNSTABLE } from "../core/m.js";
|
|
3
|
+
import { SUPPORTED_ROOM_VERSIONS, roomVersionFeatures, splitStateKey, toClientEvent } from "../core/events.js";
|
|
4
|
+
import { MatrixError, errors } from "../core/errors.js";
|
|
5
|
+
import { isValidAlias, isValidRoomId, isValidUserId, parseId } from "../core/ids.js";
|
|
6
|
+
import { getState, membershipOf, ownerOfStateKey, powerLevels, roomCreators, userLevel } from "../core/authrules.js";
|
|
7
|
+
import { validatePowerLevelContent } from "../core/rooms.js";
|
|
8
|
+
import { assertNotSuspended } from "../core/auth.js";
|
|
9
|
+
import { number, string, unknown } from "../vendor/zod/v4/classic/schemas.js";
|
|
10
|
+
import { body, fail, lenient } from "../http/input.js";
|
|
11
|
+
import { procedure } from "../http/procedure.js";
|
|
12
|
+
import { matchesFilter, parseRoomEventFilter } from "./filters.js";
|
|
13
|
+
//#region src/api/rooms.ts
|
|
14
|
+
const parseToken = (token) => {
|
|
15
|
+
if (token === null || token === "") return null;
|
|
16
|
+
const m = /^[st]?(\d+)/.exec(token);
|
|
17
|
+
if (!m) throw errors.invalidParam("Invalid pagination token");
|
|
18
|
+
return Number(m[1]);
|
|
19
|
+
};
|
|
20
|
+
const viewerOf = (s) => ({
|
|
21
|
+
userId: s.userId,
|
|
22
|
+
deviceId: s.deviceId
|
|
23
|
+
});
|
|
24
|
+
async function requireRoom(hs, roomId) {
|
|
25
|
+
if (!isValidRoomId(roomId)) throw errors.invalidParam("Invalid room ID");
|
|
26
|
+
const version = await hs.rooms.roomVersion(roomId);
|
|
27
|
+
if (!version) throw errors.forbidden("Room not found or you are not a member");
|
|
28
|
+
return version;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* State visible to the user: current state when joined (or world readable),
|
|
32
|
+
* otherwise the state at the moment they left.
|
|
33
|
+
*/
|
|
34
|
+
async function visibleState(hs, roomId, userId) {
|
|
35
|
+
await requireRoom(hs, roomId);
|
|
36
|
+
const { joined, worldReadable, membership } = await hs.rooms.canPeek(roomId, userId);
|
|
37
|
+
if (joined || worldReadable) return hs.rooms.currentState(roomId);
|
|
38
|
+
if ((await hs.db.findOne("memberships", [{
|
|
39
|
+
field: "room_id",
|
|
40
|
+
value: roomId
|
|
41
|
+
}, {
|
|
42
|
+
field: "user_id",
|
|
43
|
+
value: userId
|
|
44
|
+
}]))?.forgotten) throw errors.forbidden("You have forgotten this room");
|
|
45
|
+
if (membership === "leave" || membership === "ban") {
|
|
46
|
+
const ev = await hs.rooms.membershipEvent(roomId, userId);
|
|
47
|
+
if (ev && ev.pdu.sender !== void 0) {
|
|
48
|
+
if (ev.pdu.unsigned?.prev_content?.membership === "join" || membership === "ban") return hs.rooms.stateAtSnapshot(ev.snapshot);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw errors.forbidden("You are not a member of this room");
|
|
52
|
+
}
|
|
53
|
+
/** A gap event reached by backwards pagination (in the page, or anywhere older when the page ran out). */
|
|
54
|
+
async function findGapBefore(hs, roomId, events, end) {
|
|
55
|
+
if (!hs.federation.enabled) return null;
|
|
56
|
+
const inPage = events.find((e) => e.gap);
|
|
57
|
+
if (inPage) return inPage;
|
|
58
|
+
if (end !== null) return null;
|
|
59
|
+
const rows = await hs.db.findMany("events", {
|
|
60
|
+
where: [{
|
|
61
|
+
field: "room_id",
|
|
62
|
+
value: roomId
|
|
63
|
+
}, {
|
|
64
|
+
field: "gap",
|
|
65
|
+
value: true
|
|
66
|
+
}],
|
|
67
|
+
limit: 1
|
|
68
|
+
});
|
|
69
|
+
return rows[0] ? hs.rooms.rowToStored(rows[0]) : null;
|
|
70
|
+
}
|
|
71
|
+
/** Backfill the history behind a gap event, then clear its gap flag. */
|
|
72
|
+
async function backfillGap(hs, roomId, gapEvent) {
|
|
73
|
+
const fetched = await hs.federation.backfill(roomId, [gapEvent.pdu.event_id], 100);
|
|
74
|
+
await hs.db.update("events", [{
|
|
75
|
+
field: "event_id",
|
|
76
|
+
value: gapEvent.pdu.event_id
|
|
77
|
+
}], { gap: false });
|
|
78
|
+
hs.rooms.forgetEventCache(gapEvent.pdu.event_id);
|
|
79
|
+
return fetched > 0;
|
|
80
|
+
}
|
|
81
|
+
/** Backfill from other servers when our oldest event has unknown predecessors. Returns whether anything was fetched. */
|
|
82
|
+
async function backfillIfNeeded(hs, roomId) {
|
|
83
|
+
if (!hs.federation.enabled) return false;
|
|
84
|
+
const first = (await hs.db.findMany("events", {
|
|
85
|
+
where: [{
|
|
86
|
+
field: "room_id",
|
|
87
|
+
value: roomId
|
|
88
|
+
}],
|
|
89
|
+
sortBy: {
|
|
90
|
+
field: "stream",
|
|
91
|
+
direction: "asc"
|
|
92
|
+
},
|
|
93
|
+
limit: 1
|
|
94
|
+
}))[0];
|
|
95
|
+
if (!first || first.type === M.ROOM.CREATE) return false;
|
|
96
|
+
const prevs = (first.json.prev_events ?? []).map((p) => Array.isArray(p) ? p[0] : p);
|
|
97
|
+
const known = await hs.rooms.getEvents(prevs);
|
|
98
|
+
if (prevs.every((p) => known.has(p))) return false;
|
|
99
|
+
return await hs.federation.backfill(roomId, [first.event_id], 50) > 0;
|
|
100
|
+
}
|
|
101
|
+
function parsePageToken(raw) {
|
|
102
|
+
if (raw === null || raw === "") return null;
|
|
103
|
+
const topo = /^t(-?\d+)_(-?\d+)$/.exec(raw);
|
|
104
|
+
if (topo) return {
|
|
105
|
+
depth: Number(topo[1]),
|
|
106
|
+
stream: Number(topo[2])
|
|
107
|
+
};
|
|
108
|
+
const stream = /^s?(-?\d+)/.exec(raw);
|
|
109
|
+
if (stream) return {
|
|
110
|
+
depth: null,
|
|
111
|
+
stream: Number(stream[1])
|
|
112
|
+
};
|
|
113
|
+
throw errors.invalidParam("Invalid pagination token");
|
|
114
|
+
}
|
|
115
|
+
const topoToken = (ev) => `t${ev.pdu.depth}_${ev.stream}`;
|
|
116
|
+
/** Topological comparison of (depth, stream) pairs. */
|
|
117
|
+
const cmp = (a, b) => a.depth - b.depth || a.stream - b.stream;
|
|
118
|
+
/**
|
|
119
|
+
* Fetch room events in topological order (as /messages requires), applying history
|
|
120
|
+
* visibility and filters, until `limit` events are found.
|
|
121
|
+
*/
|
|
122
|
+
async function paginate(hs, userId, roomId, opts) {
|
|
123
|
+
const back = opts.dir === "b";
|
|
124
|
+
const out = [];
|
|
125
|
+
let cursor = opts.from && opts.from.depth !== null ? {
|
|
126
|
+
depth: opts.from.depth,
|
|
127
|
+
stream: opts.from.stream
|
|
128
|
+
} : null;
|
|
129
|
+
const streamBound = opts.from && opts.from.depth === null ? opts.from.stream : null;
|
|
130
|
+
const batch = Math.max(opts.limit * 2, 50);
|
|
131
|
+
const beyond = (pos) => {
|
|
132
|
+
if (!opts.to) return false;
|
|
133
|
+
if (opts.to.depth === null) return back ? pos.stream <= opts.to.stream : pos.stream > opts.to.stream;
|
|
134
|
+
const c = cmp(pos, {
|
|
135
|
+
depth: opts.to.depth,
|
|
136
|
+
stream: opts.to.stream
|
|
137
|
+
});
|
|
138
|
+
return back ? c <= 0 : c > 0;
|
|
139
|
+
};
|
|
140
|
+
for (;;) {
|
|
141
|
+
const where = [{
|
|
142
|
+
field: "room_id",
|
|
143
|
+
value: roomId
|
|
144
|
+
}];
|
|
145
|
+
if (cursor) where.push({
|
|
146
|
+
field: "depth",
|
|
147
|
+
operator: back ? "lte" : "gte",
|
|
148
|
+
value: cursor.depth
|
|
149
|
+
});
|
|
150
|
+
if (streamBound !== null) where.push({
|
|
151
|
+
field: "stream",
|
|
152
|
+
operator: back ? "lte" : "gt",
|
|
153
|
+
value: streamBound
|
|
154
|
+
});
|
|
155
|
+
let rows = await hs.db.findMany("events", {
|
|
156
|
+
where,
|
|
157
|
+
sortBy: {
|
|
158
|
+
field: "depth",
|
|
159
|
+
direction: back ? "desc" : "asc"
|
|
160
|
+
},
|
|
161
|
+
limit: batch
|
|
162
|
+
});
|
|
163
|
+
const full = rows.length === batch;
|
|
164
|
+
if (full) {
|
|
165
|
+
const edge = rows[rows.length - 1].depth;
|
|
166
|
+
rows = rows.filter((r) => r.depth !== edge);
|
|
167
|
+
rows.push(...await hs.db.findMany("events", { where: [...where, {
|
|
168
|
+
field: "depth",
|
|
169
|
+
value: edge
|
|
170
|
+
}] }));
|
|
171
|
+
}
|
|
172
|
+
const stored = rows.map((r) => hs.rooms.rowToStored(r)).map((ev) => ({
|
|
173
|
+
ev,
|
|
174
|
+
pos: {
|
|
175
|
+
depth: ev.pdu.depth,
|
|
176
|
+
stream: ev.stream
|
|
177
|
+
}
|
|
178
|
+
})).filter(({ pos }) => !cursor || (back ? cmp(pos, cursor) < 0 : cmp(pos, cursor) > 0)).sort((a, b) => back ? cmp(b.pos, a.pos) : cmp(a.pos, b.pos));
|
|
179
|
+
if (stored.length === 0) return {
|
|
180
|
+
events: out,
|
|
181
|
+
end: null
|
|
182
|
+
};
|
|
183
|
+
const visible = await hs.rooms.filterVisible(userId, roomId, stored.map((x) => x.ev));
|
|
184
|
+
const visibleById = new Map(visible.map((v) => [v.pdu.event_id, v]));
|
|
185
|
+
for (const { ev: raw, pos } of stored) {
|
|
186
|
+
if (beyond(pos)) return {
|
|
187
|
+
events: out,
|
|
188
|
+
end: null
|
|
189
|
+
};
|
|
190
|
+
cursor = pos;
|
|
191
|
+
const ev = visibleById.get(raw.pdu.event_id);
|
|
192
|
+
if (!ev || !matchesFilter(opts.filter, ev.pdu)) continue;
|
|
193
|
+
out.push(ev);
|
|
194
|
+
if (out.length >= opts.limit) return {
|
|
195
|
+
events: out,
|
|
196
|
+
end: `t${pos.depth}_${pos.stream}`
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (!full) return {
|
|
200
|
+
events: out,
|
|
201
|
+
end: null
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Invite by email address or phone number (spec "Third-party invites"): invite the Matrix user the
|
|
207
|
+
* identity server knows for it, or store the invite there and emit an m.room.third_party_invite.
|
|
208
|
+
*/
|
|
209
|
+
async function inviteByThreepid(ctx, s, body) {
|
|
210
|
+
const hs = ctx.hs;
|
|
211
|
+
const roomId = ctx.params.roomId;
|
|
212
|
+
for (const k of [
|
|
213
|
+
"id_server",
|
|
214
|
+
"id_access_token",
|
|
215
|
+
"medium",
|
|
216
|
+
"address"
|
|
217
|
+
]) if (typeof body[k] !== "string") throw errors.missingParam(`Missing ${k}`);
|
|
218
|
+
await requireRoom(hs, roomId);
|
|
219
|
+
await hs.rooms.requireJoined(roomId, s.userId);
|
|
220
|
+
const mxid = await hs.identityServers.lookup(body.id_server, body.id_access_token, body.medium, body.address);
|
|
221
|
+
if (mxid) {
|
|
222
|
+
if (await hs.rooms.membership(roomId, mxid) === "ban") throw errors.forbidden("User is banned");
|
|
223
|
+
await hs.rooms.invite(roomId, s.userId, mxid);
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
const state = await hs.rooms.currentState(roomId);
|
|
227
|
+
const profile = await hs.profile(s.userId);
|
|
228
|
+
const create = getState(state, M.ROOM.CREATE, "");
|
|
229
|
+
const invite = {
|
|
230
|
+
medium: body.medium,
|
|
231
|
+
address: body.address,
|
|
232
|
+
room_id: roomId,
|
|
233
|
+
sender: s.userId,
|
|
234
|
+
room_alias: getState(state, M.ROOM.CANONICAL_ALIAS, "")?.content?.alias,
|
|
235
|
+
room_avatar_url: getState(state, M.ROOM.AVATAR, "")?.content?.url,
|
|
236
|
+
room_join_rules: getState(state, M.ROOM.JOIN_RULES, "")?.content?.join_rule,
|
|
237
|
+
room_name: getState(state, M.ROOM.NAME, "")?.content?.name,
|
|
238
|
+
room_type: create?.content?.type,
|
|
239
|
+
sender_display_name: profile.displayname,
|
|
240
|
+
sender_avatar_url: profile.avatar_url
|
|
241
|
+
};
|
|
242
|
+
for (const k of Object.keys(invite)) if (invite[k] === void 0 || invite[k] === null) delete invite[k];
|
|
243
|
+
const stored = await hs.identityServers.storeInvite(body.id_server, body.id_access_token, invite);
|
|
244
|
+
const [first] = stored.public_keys;
|
|
245
|
+
await hs.rooms.sendEvent(roomId, s.userId, {
|
|
246
|
+
type: M.ROOM.THIRD_PARTY_INVITE,
|
|
247
|
+
state_key: stored.token,
|
|
248
|
+
content: {
|
|
249
|
+
display_name: stored.display_name,
|
|
250
|
+
key_validity_url: first?.key_validity_url,
|
|
251
|
+
public_key: first?.public_key,
|
|
252
|
+
public_keys: stored.public_keys
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
async function ownEvent(hs, userId, eventId) {
|
|
258
|
+
return typeof eventId === "string" && (await hs.rooms.getPdu(eventId))?.sender === userId;
|
|
259
|
+
}
|
|
260
|
+
const membershipBody = body({
|
|
261
|
+
user_id: lenient(string()),
|
|
262
|
+
reason: lenient(string())
|
|
263
|
+
});
|
|
264
|
+
async function sendMembership(ctx, membership) {
|
|
265
|
+
const s = ctx.session;
|
|
266
|
+
const roomId = ctx.params.roomId;
|
|
267
|
+
if (membership !== "leave") await assertNotSuspended(ctx.hs, s, roomId);
|
|
268
|
+
const body = ctx.input;
|
|
269
|
+
const hs = ctx.hs;
|
|
270
|
+
const reason = body.reason;
|
|
271
|
+
if (membership === "leave") {
|
|
272
|
+
const row = await hs.db.findOne("memberships", [{
|
|
273
|
+
field: "room_id",
|
|
274
|
+
value: roomId
|
|
275
|
+
}, {
|
|
276
|
+
field: "user_id",
|
|
277
|
+
value: s.userId
|
|
278
|
+
}]);
|
|
279
|
+
if (!await hs.rooms.exists(roomId) && !row) throw errors.notFound("Room not found");
|
|
280
|
+
const current = await hs.rooms.serverIsJoined(roomId) ? await hs.rooms.membership(roomId, s.userId) : row?.membership ?? "leave";
|
|
281
|
+
if (current === "leave") return {};
|
|
282
|
+
if (current === "ban") throw errors.forbidden("You are banned from this room");
|
|
283
|
+
if (current === "invite" && await hs.admin.isNoticeRoom(roomId)) throw new MatrixError(403, "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM", "Server notice invites cannot be rejected");
|
|
284
|
+
await hs.rooms.setMembership(roomId, s.userId, s.userId, "leave", reason);
|
|
285
|
+
return {};
|
|
286
|
+
}
|
|
287
|
+
if (!body.user_id || !isValidUserId(body.user_id)) throw errors.invalidParam("Invalid user_id");
|
|
288
|
+
const target = body.user_id;
|
|
289
|
+
await requireRoom(hs, roomId);
|
|
290
|
+
if (membership === "unban") {
|
|
291
|
+
if (await hs.rooms.membership(roomId, target) !== "ban") throw errors.forbidden("User is not banned");
|
|
292
|
+
await hs.rooms.setMembership(roomId, s.userId, target, "leave", reason);
|
|
293
|
+
} else if (membership === "kick") {
|
|
294
|
+
const current = await hs.rooms.membership(roomId, target);
|
|
295
|
+
if (current === "leave" || current === "ban") throw errors.forbidden("User is not in the room");
|
|
296
|
+
await hs.rooms.setMembership(roomId, s.userId, target, "leave", reason);
|
|
297
|
+
} else await hs.rooms.setMembership(roomId, s.userId, target, "ban", reason);
|
|
298
|
+
await hs.audit.record({
|
|
299
|
+
action: membership === "kick" ? "member_kick" : membership === "ban" ? "member_ban" : "member_unban",
|
|
300
|
+
actor: s.userId,
|
|
301
|
+
target: body.user_id,
|
|
302
|
+
ip: ctx.ip,
|
|
303
|
+
details: {
|
|
304
|
+
room_id: roomId,
|
|
305
|
+
reason
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return {};
|
|
309
|
+
}
|
|
310
|
+
async function validateStateEvent(hs, roomId, type, content, stateKeyValue = "") {
|
|
311
|
+
if (type === M.ROOM.CREATE) throw errors.badJson("The create event cannot be sent");
|
|
312
|
+
if (stateKeyValue.startsWith("@") && roomVersionFeatures(await hs.rooms.roomVersion(roomId)).ownedState && !ownerOfStateKey(stateKeyValue)) throw errors.badJson("Malformed user-owned state key");
|
|
313
|
+
if (type === M.ROOM.POWER_LEVELS) {
|
|
314
|
+
const version = await hs.rooms.roomVersion(roomId);
|
|
315
|
+
validatePowerLevelContent(content, roomCreators(await hs.rooms.currentState(roomId), version));
|
|
316
|
+
}
|
|
317
|
+
if (type === M.ROOM.CANONICAL_ALIAS) {
|
|
318
|
+
const aliases = [content.alias, ...Array.isArray(content.alt_aliases) ? content.alt_aliases : []].filter((a) => a !== void 0 && a !== null);
|
|
319
|
+
if (content.alt_aliases !== void 0 && !Array.isArray(content.alt_aliases)) throw new MatrixError(400, "M_INVALID_PARAM", "alt_aliases must be a list");
|
|
320
|
+
for (const alias of aliases) {
|
|
321
|
+
if (!isValidAlias(alias)) throw new MatrixError(400, "M_INVALID_PARAM", "Invalid alias");
|
|
322
|
+
const row = await hs.db.findOne("room_aliases", [{
|
|
323
|
+
field: "alias",
|
|
324
|
+
value: alias
|
|
325
|
+
}]);
|
|
326
|
+
if (!row || row.room_id !== roomId) throw new MatrixError(400, "M_BAD_ALIAS", `Alias ${alias} does not point to this room`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function publicRoomChunk(hs, roomId) {
|
|
331
|
+
const state = await hs.rooms.currentState(roomId);
|
|
332
|
+
const get = (t) => getState(state, t)?.content;
|
|
333
|
+
const chunk = {
|
|
334
|
+
room_id: roomId,
|
|
335
|
+
num_joined_members: (await hs.rooms.joinedMembers(roomId)).length,
|
|
336
|
+
world_readable: get(M.ROOM.HISTORY_VISIBILITY)?.history_visibility === "world_readable",
|
|
337
|
+
guest_can_join: get(M.ROOM.GUEST_ACCESS)?.guest_access === "can_join",
|
|
338
|
+
join_rule: get(M.ROOM.JOIN_RULES)?.join_rule ?? "invite"
|
|
339
|
+
};
|
|
340
|
+
if (typeof get(M.ROOM.NAME)?.name === "string") chunk.name = get(M.ROOM.NAME).name;
|
|
341
|
+
if (typeof get(M.ROOM.TOPIC)?.topic === "string") chunk.topic = get(M.ROOM.TOPIC).topic;
|
|
342
|
+
if (typeof get(M.ROOM.CANONICAL_ALIAS)?.alias === "string") chunk.canonical_alias = get(M.ROOM.CANONICAL_ALIAS).alias;
|
|
343
|
+
if (typeof get(M.ROOM.AVATAR)?.url === "string") chunk.avatar_url = get(M.ROOM.AVATAR).url;
|
|
344
|
+
const roomType = get(M.ROOM.CREATE)?.type;
|
|
345
|
+
if (typeof roomType === "string") chunk.room_type = roomType;
|
|
346
|
+
return chunk;
|
|
347
|
+
}
|
|
348
|
+
async function publicRooms(ctx, body) {
|
|
349
|
+
const hs = ctx.hs;
|
|
350
|
+
const limit = Number(body.limit ?? ctx.query.get("limit") ?? 0) || void 0;
|
|
351
|
+
const since = body.since ?? ctx.query.get("since");
|
|
352
|
+
const offset = since ? Number.parseInt(String(since).replace(/^o/, ""), 10) || 0 : 0;
|
|
353
|
+
const term = isObject(body.filter) && typeof body.filter.generic_search_term === "string" ? body.filter.generic_search_term.toLowerCase() : null;
|
|
354
|
+
const roomTypes = isObject(body.filter) && Array.isArray(body.filter.room_types) ? body.filter.room_types : null;
|
|
355
|
+
const rooms = await hs.db.findMany("rooms", {
|
|
356
|
+
where: [{
|
|
357
|
+
field: "is_public",
|
|
358
|
+
value: true
|
|
359
|
+
}],
|
|
360
|
+
sortBy: {
|
|
361
|
+
field: "created_ts",
|
|
362
|
+
direction: "asc"
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
let chunks = await Promise.all(rooms.map((r) => publicRoomChunk(hs, r.room_id)));
|
|
366
|
+
if (term) chunks = chunks.filter((c) => [
|
|
367
|
+
c.name,
|
|
368
|
+
c.topic,
|
|
369
|
+
c.canonical_alias
|
|
370
|
+
].some((v) => typeof v === "string" && v.toLowerCase().includes(term)));
|
|
371
|
+
if (roomTypes) chunks = chunks.filter((c) => roomTypes.includes(c.room_type ?? null));
|
|
372
|
+
chunks.sort((a, b) => b.num_joined_members - a.num_joined_members);
|
|
373
|
+
const out = {
|
|
374
|
+
chunk: limit ? chunks.slice(offset, offset + limit) : chunks.slice(offset),
|
|
375
|
+
total_room_count_estimate: chunks.length
|
|
376
|
+
};
|
|
377
|
+
if (limit && offset + limit < chunks.length) out.next_batch = `o${offset + limit}`;
|
|
378
|
+
if (offset > 0) out.prev_batch = `o${Math.max(0, offset - (limit ?? 0))}`;
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
async function canDeleteAlias(hs, s, alias) {
|
|
382
|
+
if (alias.creator === s.userId || s.isAdmin) return true;
|
|
383
|
+
const version = await hs.rooms.roomVersion(alias.room_id);
|
|
384
|
+
if (!version) return false;
|
|
385
|
+
const state = await hs.rooms.currentState(alias.room_id);
|
|
386
|
+
const pl = powerLevels(state, version);
|
|
387
|
+
const needed = pl.events[M.ROOM.CANONICAL_ALIAS] ?? pl.state_default;
|
|
388
|
+
return membershipOf(state, s.userId) === "join" && userLevel(pl, s.userId) >= needed;
|
|
389
|
+
}
|
|
390
|
+
async function upgradeRoom(hs, s, roomId, newVersion, additionalCreators) {
|
|
391
|
+
if (!SUPPORTED_ROOM_VERSIONS.includes(newVersion)) throw errors.unsupportedRoomVersion();
|
|
392
|
+
const version = await requireRoom(hs, roomId);
|
|
393
|
+
const state = await hs.rooms.requireJoined(roomId, s.userId);
|
|
394
|
+
const pl = powerLevels(state, version);
|
|
395
|
+
const tombstoneLevel = pl.events[M.ROOM.TOMBSTONE] ?? pl.state_default;
|
|
396
|
+
if (userLevel(pl, s.userId) < tombstoneLevel) throw errors.forbidden("You cannot upgrade this room");
|
|
397
|
+
const create = getState(state, M.ROOM.CREATE);
|
|
398
|
+
const creationContent = {};
|
|
399
|
+
for (const k of [M.FEDERATE, "type"]) if (create.content[k] !== void 0) creationContent[k] = create.content[k];
|
|
400
|
+
const toV12 = roomVersionFeatures(newVersion).v12;
|
|
401
|
+
if (toV12) {
|
|
402
|
+
creationContent.predecessor = { room_id: roomId };
|
|
403
|
+
if (additionalCreators !== void 0) creationContent.additional_creators = additionalCreators;
|
|
404
|
+
} else creationContent.predecessor = {
|
|
405
|
+
room_id: roomId,
|
|
406
|
+
event_id: (await hs.db.findMany("events", {
|
|
407
|
+
where: [{
|
|
408
|
+
field: "room_id",
|
|
409
|
+
value: roomId
|
|
410
|
+
}],
|
|
411
|
+
sortBy: {
|
|
412
|
+
field: "stream",
|
|
413
|
+
direction: "desc"
|
|
414
|
+
},
|
|
415
|
+
limit: 1
|
|
416
|
+
}))[0].event_id
|
|
417
|
+
};
|
|
418
|
+
const copyTypes = [
|
|
419
|
+
M.ROOM.SERVER_ACL,
|
|
420
|
+
M.ROOM.ENCRYPTION,
|
|
421
|
+
M.ROOM.NAME,
|
|
422
|
+
M.ROOM.AVATAR,
|
|
423
|
+
M.ROOM.TOPIC,
|
|
424
|
+
M.ROOM.GUEST_ACCESS,
|
|
425
|
+
M.ROOM.HISTORY_VISIBILITY,
|
|
426
|
+
M.ROOM.JOIN_RULES,
|
|
427
|
+
M.ROOM.POWER_LEVELS
|
|
428
|
+
];
|
|
429
|
+
const initialState = [];
|
|
430
|
+
for (const t of copyTypes) {
|
|
431
|
+
const ev = getState(state, t);
|
|
432
|
+
if (ev && t !== M.ROOM.POWER_LEVELS) initialState.push({
|
|
433
|
+
type: t,
|
|
434
|
+
state_key: "",
|
|
435
|
+
content: ev.content
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
let oldPl = structuredClone(getState(state, M.ROOM.POWER_LEVELS)?.content ?? {});
|
|
439
|
+
const isPublic = (await hs.db.findOne("rooms", [{
|
|
440
|
+
field: "room_id",
|
|
441
|
+
value: roomId
|
|
442
|
+
}]))?.is_public;
|
|
443
|
+
let override;
|
|
444
|
+
if (toV12) {
|
|
445
|
+
const creators = /* @__PURE__ */ new Set([s.userId, ...Array.isArray(additionalCreators) ? additionalCreators : []]);
|
|
446
|
+
const users = { ...oldPl.users };
|
|
447
|
+
for (const u of creators) delete users[u];
|
|
448
|
+
oldPl = {
|
|
449
|
+
...oldPl,
|
|
450
|
+
users
|
|
451
|
+
};
|
|
452
|
+
override = oldPl;
|
|
453
|
+
} else {
|
|
454
|
+
const level = userLevel(pl, s.userId);
|
|
455
|
+
override = {
|
|
456
|
+
...oldPl,
|
|
457
|
+
users: {
|
|
458
|
+
...oldPl.users,
|
|
459
|
+
[s.userId]: Number.isFinite(level) ? Math.max(level, 100) : 100
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const { room_id: newRoomId } = await hs.rooms.createRoom(s.userId, {
|
|
464
|
+
room_version: newVersion,
|
|
465
|
+
creation_content: creationContent,
|
|
466
|
+
initial_state: initialState,
|
|
467
|
+
power_level_content_override: override,
|
|
468
|
+
preset: "private_chat",
|
|
469
|
+
visibility: isPublic ? "public" : "private"
|
|
470
|
+
});
|
|
471
|
+
if (getState(state, M.ROOM.POWER_LEVELS) && !toV12) try {
|
|
472
|
+
await hs.rooms.sendEvent(newRoomId, s.userId, {
|
|
473
|
+
type: M.ROOM.POWER_LEVELS,
|
|
474
|
+
state_key: "",
|
|
475
|
+
content: oldPl
|
|
476
|
+
});
|
|
477
|
+
} catch {}
|
|
478
|
+
const aliases = await hs.db.findMany("room_aliases", { where: [{
|
|
479
|
+
field: "room_id",
|
|
480
|
+
value: roomId
|
|
481
|
+
}] });
|
|
482
|
+
for (const a of aliases) await hs.db.update("room_aliases", [{
|
|
483
|
+
field: "alias",
|
|
484
|
+
value: a.alias
|
|
485
|
+
}], { room_id: newRoomId });
|
|
486
|
+
const canonical = getState(state, M.ROOM.CANONICAL_ALIAS);
|
|
487
|
+
if (canonical && Object.keys(canonical.content).length) {
|
|
488
|
+
await hs.rooms.sendEvent(roomId, s.userId, {
|
|
489
|
+
type: M.ROOM.CANONICAL_ALIAS,
|
|
490
|
+
state_key: "",
|
|
491
|
+
content: {}
|
|
492
|
+
}).catch(() => {});
|
|
493
|
+
await hs.rooms.sendEvent(newRoomId, s.userId, {
|
|
494
|
+
type: M.ROOM.CANONICAL_ALIAS,
|
|
495
|
+
state_key: "",
|
|
496
|
+
content: canonical.content
|
|
497
|
+
}).catch(() => {});
|
|
498
|
+
}
|
|
499
|
+
if (isPublic) await hs.db.update("rooms", [{
|
|
500
|
+
field: "room_id",
|
|
501
|
+
value: roomId
|
|
502
|
+
}], { is_public: false });
|
|
503
|
+
await hs.rooms.sendEvent(roomId, s.userId, {
|
|
504
|
+
type: M.ROOM.TOMBSTONE,
|
|
505
|
+
state_key: "",
|
|
506
|
+
content: {
|
|
507
|
+
body: "This room has been replaced",
|
|
508
|
+
replacement_room: newRoomId
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
const restricted = {
|
|
512
|
+
...oldPl,
|
|
513
|
+
events_default: Math.max(50, oldPl.events_default ?? 0),
|
|
514
|
+
invite: Math.max(50, oldPl.invite ?? 0)
|
|
515
|
+
};
|
|
516
|
+
await hs.rooms.sendEvent(roomId, s.userId, {
|
|
517
|
+
type: M.ROOM.POWER_LEVELS,
|
|
518
|
+
state_key: "",
|
|
519
|
+
content: restricted
|
|
520
|
+
}).catch(() => {});
|
|
521
|
+
return newRoomId;
|
|
522
|
+
}
|
|
523
|
+
function registerRoomRoutes(r) {
|
|
524
|
+
r.client("POST", "/createRoom", procedure({
|
|
525
|
+
auth: "user",
|
|
526
|
+
input: body({}),
|
|
527
|
+
handler: async (ctx) => {
|
|
528
|
+
const s = ctx.session;
|
|
529
|
+
if (s.isGuest) throw errors.forbidden("Guests cannot create rooms");
|
|
530
|
+
await assertNotSuspended(ctx.hs, s);
|
|
531
|
+
await ctx.rateLimit("createRoom");
|
|
532
|
+
const created = await ctx.hs.rooms.createRoom(s.userId, ctx.input);
|
|
533
|
+
await ctx.hs.audit.record({
|
|
534
|
+
action: "room_create",
|
|
535
|
+
actor: s.userId,
|
|
536
|
+
target: created.room_id,
|
|
537
|
+
ip: ctx.ip
|
|
538
|
+
});
|
|
539
|
+
return created;
|
|
540
|
+
}
|
|
541
|
+
}));
|
|
542
|
+
r.client("GET", "/joined_rooms", procedure({
|
|
543
|
+
auth: "user",
|
|
544
|
+
handler: async (ctx) => {
|
|
545
|
+
const s = ctx.session;
|
|
546
|
+
return { joined_rooms: await ctx.hs.rooms.roomsForUser(s.userId) };
|
|
547
|
+
}
|
|
548
|
+
}));
|
|
549
|
+
const joinBody = body({
|
|
550
|
+
reason: lenient(string()),
|
|
551
|
+
third_party_signed: unknown().optional()
|
|
552
|
+
});
|
|
553
|
+
const join = async (ctx, idOrAlias) => {
|
|
554
|
+
const s = ctx.session;
|
|
555
|
+
const body = ctx.input;
|
|
556
|
+
const { roomId, servers: resolvedServers } = await ctx.hs.rooms.resolveRoom(idOrAlias);
|
|
557
|
+
await assertNotSuspended(ctx.hs, s, roomId);
|
|
558
|
+
const { reason, membership, join_authorised_via_users_server, third_party_signed, ...custom } = body;
|
|
559
|
+
const servers = [
|
|
560
|
+
...ctx.query.getAll("via"),
|
|
561
|
+
...ctx.query.getAll("server_name"),
|
|
562
|
+
...resolvedServers
|
|
563
|
+
];
|
|
564
|
+
if (isObject(third_party_signed)) {
|
|
565
|
+
const signed = third_party_signed;
|
|
566
|
+
if (typeof signed.sender !== "string" || signed.mxid !== s.userId || typeof signed.token !== "string") throw errors.badJson("Invalid third_party_signed");
|
|
567
|
+
const sender = signed.sender;
|
|
568
|
+
const proof = signed;
|
|
569
|
+
if (await ctx.hs.rooms.membership(roomId, s.userId) !== "invite") {
|
|
570
|
+
const event = {
|
|
571
|
+
type: M.ROOM.MEMBER,
|
|
572
|
+
room_id: roomId,
|
|
573
|
+
sender,
|
|
574
|
+
state_key: s.userId,
|
|
575
|
+
content: {
|
|
576
|
+
membership: "invite",
|
|
577
|
+
third_party_invite: { signed: proof }
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
if (ctx.hs.isLocalUser(sender)) {
|
|
581
|
+
const pending = getState(await ctx.hs.rooms.currentState(roomId), M.ROOM.THIRD_PARTY_INVITE, signed.token);
|
|
582
|
+
if (!pending || pending.sender !== sender) throw errors.forbidden("No such third-party invite");
|
|
583
|
+
await ctx.hs.rooms.invite(roomId, sender, s.userId, { thirdPartyInvite: {
|
|
584
|
+
display_name: pending.content?.display_name ?? "",
|
|
585
|
+
signed: proof
|
|
586
|
+
} });
|
|
587
|
+
} else await ctx.hs.federation.client.request(sender.slice(sender.indexOf(":") + 1), "PUT", `/_matrix/federation/v1/exchange_third_party_invite/${encodeURIComponent(roomId)}`, { body: event });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
await ctx.hs.rooms.join(s.userId, roomId, {
|
|
591
|
+
reason,
|
|
592
|
+
content: custom,
|
|
593
|
+
servers
|
|
594
|
+
});
|
|
595
|
+
return { room_id: roomId };
|
|
596
|
+
};
|
|
597
|
+
r.client("POST", "/rooms/:roomId/join", procedure({
|
|
598
|
+
auth: "user",
|
|
599
|
+
rateLimit: "join",
|
|
600
|
+
input: joinBody,
|
|
601
|
+
handler: (ctx) => join(ctx, ctx.params.roomId)
|
|
602
|
+
}));
|
|
603
|
+
r.client("POST", "/join/:roomIdOrAlias", procedure({
|
|
604
|
+
auth: "user",
|
|
605
|
+
rateLimit: "join",
|
|
606
|
+
input: joinBody,
|
|
607
|
+
handler: (ctx) => join(ctx, ctx.params.roomIdOrAlias)
|
|
608
|
+
}));
|
|
609
|
+
r.client("POST", "/knock/:roomIdOrAlias", procedure({
|
|
610
|
+
auth: "user",
|
|
611
|
+
input: body({ reason: lenient(string()) }),
|
|
612
|
+
handler: async (ctx) => {
|
|
613
|
+
const s = ctx.session;
|
|
614
|
+
const body = ctx.input;
|
|
615
|
+
const { roomId, servers } = await ctx.hs.rooms.resolveRoom(ctx.params.roomIdOrAlias);
|
|
616
|
+
await assertNotSuspended(ctx.hs, s, roomId);
|
|
617
|
+
const via = [
|
|
618
|
+
...ctx.query.getAll("via"),
|
|
619
|
+
...ctx.query.getAll("server_name"),
|
|
620
|
+
...servers
|
|
621
|
+
];
|
|
622
|
+
await ctx.hs.rooms.setMembership(roomId, s.userId, s.userId, "knock", body.reason, via);
|
|
623
|
+
return { room_id: roomId };
|
|
624
|
+
}
|
|
625
|
+
}));
|
|
626
|
+
const inviteBody = body({
|
|
627
|
+
user_id: lenient(string()),
|
|
628
|
+
reason: lenient(string())
|
|
629
|
+
});
|
|
630
|
+
r.client("POST", "/rooms/:roomId/invite", procedure({
|
|
631
|
+
auth: "user",
|
|
632
|
+
input: inviteBody,
|
|
633
|
+
handler: async (ctx) => {
|
|
634
|
+
const s = ctx.session;
|
|
635
|
+
const body = ctx.input;
|
|
636
|
+
await assertNotSuspended(ctx.hs, s, ctx.params.roomId);
|
|
637
|
+
await ctx.rateLimit("invite");
|
|
638
|
+
if (body.id_server !== void 0 || body.medium !== void 0) return inviteByThreepid(ctx, s, body);
|
|
639
|
+
if (!body.user_id || !isValidUserId(body.user_id)) throw errors.invalidParam("Invalid user_id");
|
|
640
|
+
await requireRoom(ctx.hs, ctx.params.roomId);
|
|
641
|
+
const current = await ctx.hs.rooms.membership(ctx.params.roomId, body.user_id);
|
|
642
|
+
if (current === "ban") throw errors.forbidden("User is banned");
|
|
643
|
+
if (current === "join") throw errors.forbidden("User is already joined");
|
|
644
|
+
await ctx.hs.rooms.invite(ctx.params.roomId, s.userId, body.user_id, { reason: body.reason });
|
|
645
|
+
return {};
|
|
646
|
+
}
|
|
647
|
+
}));
|
|
648
|
+
r.client("POST", "/rooms/:roomId/leave", procedure({
|
|
649
|
+
auth: "user",
|
|
650
|
+
input: membershipBody,
|
|
651
|
+
handler: (ctx) => sendMembership(ctx, "leave")
|
|
652
|
+
}));
|
|
653
|
+
r.client("POST", "/rooms/:roomId/kick", procedure({
|
|
654
|
+
auth: "user",
|
|
655
|
+
input: membershipBody,
|
|
656
|
+
handler: (ctx) => sendMembership(ctx, "kick")
|
|
657
|
+
}));
|
|
658
|
+
r.client("POST", "/rooms/:roomId/ban", procedure({
|
|
659
|
+
auth: "user",
|
|
660
|
+
input: membershipBody,
|
|
661
|
+
handler: (ctx) => sendMembership(ctx, "ban")
|
|
662
|
+
}));
|
|
663
|
+
r.client("POST", "/rooms/:roomId/unban", procedure({
|
|
664
|
+
auth: "user",
|
|
665
|
+
input: membershipBody,
|
|
666
|
+
handler: (ctx) => sendMembership(ctx, "unban")
|
|
667
|
+
}));
|
|
668
|
+
r.client("POST", "/rooms/:roomId/forget", procedure({
|
|
669
|
+
auth: "user",
|
|
670
|
+
handler: async (ctx) => {
|
|
671
|
+
const s = ctx.session;
|
|
672
|
+
const hs = ctx.hs;
|
|
673
|
+
const row = await hs.db.findOne("memberships", [{
|
|
674
|
+
field: "room_id",
|
|
675
|
+
value: ctx.params.roomId
|
|
676
|
+
}, {
|
|
677
|
+
field: "user_id",
|
|
678
|
+
value: s.userId
|
|
679
|
+
}]);
|
|
680
|
+
if (!row) throw errors.notFound("Not a member of this room");
|
|
681
|
+
if (row.membership === "join" || row.membership === "invite" || row.membership === "knock") throw errors.unknown("You must leave the room before forgetting it");
|
|
682
|
+
await hs.db.update("memberships", [{
|
|
683
|
+
field: "room_id",
|
|
684
|
+
value: ctx.params.roomId
|
|
685
|
+
}, {
|
|
686
|
+
field: "user_id",
|
|
687
|
+
value: s.userId
|
|
688
|
+
}], { forgotten: true });
|
|
689
|
+
return {};
|
|
690
|
+
}
|
|
691
|
+
}));
|
|
692
|
+
r.client("PUT", "/rooms/:roomId/send/:eventType/:txnId", procedure({
|
|
693
|
+
auth: "user",
|
|
694
|
+
rateLimit: "message",
|
|
695
|
+
handler: async (ctx) => {
|
|
696
|
+
const s = ctx.session;
|
|
697
|
+
const content = await ctx.json();
|
|
698
|
+
await requireRoom(ctx.hs, ctx.params.roomId);
|
|
699
|
+
if (!(s.suspended && ctx.params.eventType === M.ROOM.REDACTION && await ownEvent(ctx.hs, s.userId, content?.redacts))) await assertNotSuspended(ctx.hs, s, ctx.params.roomId);
|
|
700
|
+
const delay = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4140.DELAY);
|
|
701
|
+
if (delay !== null) return { delay_id: await ctx.hs.delayedEvents.schedule({
|
|
702
|
+
userId: s.userId,
|
|
703
|
+
deviceId: s.deviceId,
|
|
704
|
+
roomId: ctx.params.roomId,
|
|
705
|
+
type: ctx.params.eventType,
|
|
706
|
+
content,
|
|
707
|
+
delayMs: Number(delay),
|
|
708
|
+
txnId: ctx.params.txnId
|
|
709
|
+
}) };
|
|
710
|
+
const ts = s.appservice && ctx.query.get("ts") ? Number(ctx.query.get("ts")) : void 0;
|
|
711
|
+
return { event_id: await ctx.hs.rooms.sendEvent(ctx.params.roomId, s.userId, {
|
|
712
|
+
type: ctx.params.eventType,
|
|
713
|
+
content
|
|
714
|
+
}, {
|
|
715
|
+
txnId: ctx.params.txnId,
|
|
716
|
+
deviceId: s.deviceId || void 0,
|
|
717
|
+
ts
|
|
718
|
+
}) };
|
|
719
|
+
}
|
|
720
|
+
}));
|
|
721
|
+
const putState = async (ctx) => {
|
|
722
|
+
const s = await ctx.auth();
|
|
723
|
+
const content = await ctx.json();
|
|
724
|
+
const roomId = ctx.params.roomId;
|
|
725
|
+
await requireRoom(ctx.hs, roomId);
|
|
726
|
+
const type = ctx.params.eventType;
|
|
727
|
+
const key = ctx.params.stateKey ?? "";
|
|
728
|
+
if (!(type === M.ROOM.MEMBER && key === s.userId && content?.membership === "leave")) await assertNotSuspended(ctx.hs, s, roomId);
|
|
729
|
+
await validateStateEvent(ctx.hs, roomId, type, content, key);
|
|
730
|
+
const delay = ctx.query.get(UNSTABLE.ORG.MATRIX.MSC4140.DELAY);
|
|
731
|
+
if (delay !== null) return { delay_id: await ctx.hs.delayedEvents.schedule({
|
|
732
|
+
userId: s.userId,
|
|
733
|
+
deviceId: s.deviceId,
|
|
734
|
+
roomId,
|
|
735
|
+
type,
|
|
736
|
+
stateKey: key,
|
|
737
|
+
content,
|
|
738
|
+
delayMs: Number(delay)
|
|
739
|
+
}) };
|
|
740
|
+
const current = getState(await ctx.hs.rooms.currentState(roomId), type, key);
|
|
741
|
+
if (current && current.sender === s.userId && JSON.stringify(current.content) === JSON.stringify(content)) return { event_id: current.event_id };
|
|
742
|
+
const ts = s.appservice && ctx.query.get("ts") ? Number(ctx.query.get("ts")) : void 0;
|
|
743
|
+
return { event_id: await ctx.hs.rooms.sendEvent(roomId, s.userId, {
|
|
744
|
+
type,
|
|
745
|
+
state_key: key,
|
|
746
|
+
content
|
|
747
|
+
}, { ts }) };
|
|
748
|
+
};
|
|
749
|
+
r.client("PUT", "/rooms/:roomId/state/:eventType", procedure({ handler: putState }));
|
|
750
|
+
r.client("PUT", "/rooms/:roomId/state/:eventType/:stateKey", procedure({ handler: putState }));
|
|
751
|
+
const getStateEvent = async (ctx) => {
|
|
752
|
+
const s = await ctx.auth();
|
|
753
|
+
const state = await visibleState(ctx.hs, ctx.params.roomId, s.userId);
|
|
754
|
+
const ev = getState(state, ctx.params.eventType, ctx.params.stateKey ?? "");
|
|
755
|
+
if (!ev) throw errors.notFound("State event not found");
|
|
756
|
+
if (ctx.query.get("format") === "event") return ctx.hs.rooms.formatEvent({
|
|
757
|
+
pdu: ev,
|
|
758
|
+
stream: 0,
|
|
759
|
+
snapshot: 0,
|
|
760
|
+
txnId: null,
|
|
761
|
+
txnToken: null
|
|
762
|
+
});
|
|
763
|
+
return ev.content;
|
|
764
|
+
};
|
|
765
|
+
r.client("GET", "/rooms/:roomId/state/:eventType", procedure({ handler: getStateEvent }));
|
|
766
|
+
r.client("GET", "/rooms/:roomId/state/:eventType/:stateKey", procedure({ handler: getStateEvent }));
|
|
767
|
+
r.client("GET", "/rooms/:roomId/state", procedure({
|
|
768
|
+
auth: "user",
|
|
769
|
+
handler: async (ctx) => {
|
|
770
|
+
const s = ctx.session;
|
|
771
|
+
return [...(await visibleState(ctx.hs, ctx.params.roomId, s.userId)).values()].map((ev) => toClientEvent(ev));
|
|
772
|
+
}
|
|
773
|
+
}));
|
|
774
|
+
r.client("GET", "/rooms/:roomId/members", procedure({
|
|
775
|
+
auth: "user",
|
|
776
|
+
handler: async (ctx) => {
|
|
777
|
+
const s = ctx.session;
|
|
778
|
+
const hs = ctx.hs;
|
|
779
|
+
await hs.partialState.waitForFullState(ctx.params.roomId, ctx.request.signal);
|
|
780
|
+
let state = await visibleState(hs, ctx.params.roomId, s.userId);
|
|
781
|
+
const at = parseToken(ctx.query.get("at"));
|
|
782
|
+
if (at !== null) {
|
|
783
|
+
const rows = await hs.db.findMany("events", {
|
|
784
|
+
where: [{
|
|
785
|
+
field: "room_id",
|
|
786
|
+
value: ctx.params.roomId
|
|
787
|
+
}, {
|
|
788
|
+
field: "stream",
|
|
789
|
+
operator: "lte",
|
|
790
|
+
value: at
|
|
791
|
+
}],
|
|
792
|
+
sortBy: {
|
|
793
|
+
field: "stream",
|
|
794
|
+
direction: "desc"
|
|
795
|
+
},
|
|
796
|
+
limit: 1
|
|
797
|
+
});
|
|
798
|
+
if (rows[0]) state = await hs.rooms.stateAtSnapshot(rows[0].state_snapshot);
|
|
799
|
+
else {
|
|
800
|
+
const first = await hs.db.findMany("events", {
|
|
801
|
+
where: [{
|
|
802
|
+
field: "room_id",
|
|
803
|
+
value: ctx.params.roomId
|
|
804
|
+
}],
|
|
805
|
+
sortBy: {
|
|
806
|
+
field: "stream",
|
|
807
|
+
direction: "asc"
|
|
808
|
+
},
|
|
809
|
+
limit: 2
|
|
810
|
+
});
|
|
811
|
+
if (first.length) state = await hs.rooms.stateAtSnapshot(first[first.length - 1].state_snapshot);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const membership = ctx.query.get("membership");
|
|
815
|
+
const notMembership = ctx.query.get("not_membership");
|
|
816
|
+
const chunk = [];
|
|
817
|
+
for (const [k, ev] of state) {
|
|
818
|
+
if (splitStateKey(k)[0] !== M.ROOM.MEMBER) continue;
|
|
819
|
+
const m = ev.content?.membership;
|
|
820
|
+
if (membership && m !== membership) continue;
|
|
821
|
+
if (notMembership && m === notMembership) continue;
|
|
822
|
+
chunk.push(toClientEvent(ev));
|
|
823
|
+
}
|
|
824
|
+
return { chunk };
|
|
825
|
+
}
|
|
826
|
+
}));
|
|
827
|
+
r.client("GET", "/rooms/:roomId/joined_members", procedure({
|
|
828
|
+
auth: "user",
|
|
829
|
+
handler: async (ctx) => {
|
|
830
|
+
const s = ctx.session;
|
|
831
|
+
await requireRoom(ctx.hs, ctx.params.roomId);
|
|
832
|
+
await ctx.hs.partialState.waitForFullState(ctx.params.roomId, ctx.request.signal);
|
|
833
|
+
const { joined: isJoined, worldReadable } = await ctx.hs.rooms.canPeek(ctx.params.roomId, s.userId);
|
|
834
|
+
if (!isJoined && !worldReadable) throw errors.forbidden("You are not joined to this room");
|
|
835
|
+
const state = await ctx.hs.rooms.currentState(ctx.params.roomId);
|
|
836
|
+
const joined = {};
|
|
837
|
+
for (const [k, ev] of state) {
|
|
838
|
+
const [type, key] = splitStateKey(k);
|
|
839
|
+
if (type !== M.ROOM.MEMBER || ev.content?.membership !== "join") continue;
|
|
840
|
+
joined[key] = {
|
|
841
|
+
display_name: ev.content.displayname ?? null,
|
|
842
|
+
avatar_url: ev.content.avatar_url ?? null
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
return { joined };
|
|
846
|
+
}
|
|
847
|
+
}));
|
|
848
|
+
r.client("GET", "/rooms/:roomId/messages", procedure({
|
|
849
|
+
auth: "user",
|
|
850
|
+
handler: async (ctx) => {
|
|
851
|
+
const s = ctx.session;
|
|
852
|
+
const hs = ctx.hs;
|
|
853
|
+
const roomId = ctx.params.roomId;
|
|
854
|
+
await visibleState(hs, roomId, s.userId);
|
|
855
|
+
const dir = ctx.query.get("dir") ?? "b";
|
|
856
|
+
if (dir !== "b" && dir !== "f") throw errors.invalidParam("dir must be 'b' or 'f'");
|
|
857
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "10", 10) || 10, 1), 1e3);
|
|
858
|
+
const filter = parseRoomEventFilter(ctx);
|
|
859
|
+
const fromRaw = ctx.query.get("from");
|
|
860
|
+
const from = parsePageToken(fromRaw) ?? (dir === "b" ? {
|
|
861
|
+
depth: null,
|
|
862
|
+
stream: await hs.streams.position()
|
|
863
|
+
} : null);
|
|
864
|
+
const to = parsePageToken(ctx.query.get("to"));
|
|
865
|
+
let { events, end } = await paginate(hs, s.userId, roomId, {
|
|
866
|
+
from,
|
|
867
|
+
to,
|
|
868
|
+
dir,
|
|
869
|
+
limit: filter?.limit ?? limit,
|
|
870
|
+
filter
|
|
871
|
+
});
|
|
872
|
+
for (let attempt = 0; attempt < 3 && dir === "b"; attempt++) {
|
|
873
|
+
const gapEvent = await findGapBefore(hs, roomId, events, end);
|
|
874
|
+
if (!(gapEvent ? await backfillGap(hs, roomId, gapEvent) : end === null && events.length < (filter?.limit ?? limit) && await backfillIfNeeded(hs, roomId))) break;
|
|
875
|
+
({events, end} = await paginate(hs, s.userId, roomId, {
|
|
876
|
+
from,
|
|
877
|
+
to,
|
|
878
|
+
dir,
|
|
879
|
+
limit: filter?.limit ?? limit,
|
|
880
|
+
filter
|
|
881
|
+
}));
|
|
882
|
+
}
|
|
883
|
+
const out = {
|
|
884
|
+
chunk: events.map((e) => hs.rooms.formatEvent(e, viewerOf(s))),
|
|
885
|
+
start: fromRaw ?? (from ? from.depth === null ? `s${from.stream}` : `t${from.depth}_${from.stream}` : "t0_0")
|
|
886
|
+
};
|
|
887
|
+
if (end !== null) out.end = end;
|
|
888
|
+
else if (events.length) out.end = topoToken(events[events.length - 1]);
|
|
889
|
+
if (filter?.lazy_load_members) {
|
|
890
|
+
const current = await hs.rooms.currentState(roomId);
|
|
891
|
+
out.state = [...new Set(events.map((e) => e.pdu.sender))].map((u) => getState(current, M.ROOM.MEMBER, u)).filter(Boolean).map((e) => toClientEvent(e));
|
|
892
|
+
}
|
|
893
|
+
return out;
|
|
894
|
+
}
|
|
895
|
+
}));
|
|
896
|
+
r.client("GET", "/rooms/:roomId/event/:eventId", procedure({
|
|
897
|
+
auth: "user",
|
|
898
|
+
handler: async (ctx) => {
|
|
899
|
+
const s = ctx.session;
|
|
900
|
+
const hs = ctx.hs;
|
|
901
|
+
await requireRoom(hs, ctx.params.roomId);
|
|
902
|
+
const ev = await hs.rooms.getEvent(ctx.params.eventId);
|
|
903
|
+
if (!ev || ev.pdu.room_id !== ctx.params.roomId) throw errors.notFound("Event not found");
|
|
904
|
+
if ((await hs.rooms.filterVisible(s.userId, ctx.params.roomId, [ev])).length === 0) throw errors.notFound("Event not found");
|
|
905
|
+
return hs.rooms.formatEvent(ev, viewerOf(s));
|
|
906
|
+
}
|
|
907
|
+
}));
|
|
908
|
+
r.client("GET", "/rooms/:roomId/context/:eventId", procedure({
|
|
909
|
+
auth: "user",
|
|
910
|
+
handler: async (ctx) => {
|
|
911
|
+
const s = ctx.session;
|
|
912
|
+
const hs = ctx.hs;
|
|
913
|
+
const roomId = ctx.params.roomId;
|
|
914
|
+
await requireRoom(hs, roomId);
|
|
915
|
+
const ev = await hs.rooms.getEvent(ctx.params.eventId);
|
|
916
|
+
if (!ev || ev.pdu.room_id !== roomId || (await hs.rooms.filterVisible(s.userId, roomId, [ev])).length === 0) throw errors.notFound("Event not found");
|
|
917
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "10", 10) || 10, 0), 100);
|
|
918
|
+
const filter = parseRoomEventFilter(ctx);
|
|
919
|
+
const here = {
|
|
920
|
+
depth: ev.pdu.depth,
|
|
921
|
+
stream: ev.stream
|
|
922
|
+
};
|
|
923
|
+
const before = await paginate(hs, s.userId, roomId, {
|
|
924
|
+
from: here,
|
|
925
|
+
to: null,
|
|
926
|
+
dir: "b",
|
|
927
|
+
limit: Math.floor(limit / 2) || (limit ? 1 : 0),
|
|
928
|
+
filter
|
|
929
|
+
});
|
|
930
|
+
const after = await paginate(hs, s.userId, roomId, {
|
|
931
|
+
from: here,
|
|
932
|
+
to: null,
|
|
933
|
+
dir: "f",
|
|
934
|
+
limit: Math.ceil(limit / 2),
|
|
935
|
+
filter
|
|
936
|
+
});
|
|
937
|
+
const beforeEvents = limit ? before.events : [];
|
|
938
|
+
const afterEvents = limit ? after.events : [];
|
|
939
|
+
const state = await hs.rooms.stateAtSnapshot(ev.snapshot);
|
|
940
|
+
return {
|
|
941
|
+
event: hs.rooms.formatEvent(ev, viewerOf(s)),
|
|
942
|
+
events_before: beforeEvents.map((e) => hs.rooms.formatEvent(e, viewerOf(s))),
|
|
943
|
+
events_after: afterEvents.map((e) => hs.rooms.formatEvent(e, viewerOf(s))),
|
|
944
|
+
start: topoToken(beforeEvents.length ? beforeEvents[beforeEvents.length - 1] : ev),
|
|
945
|
+
end: topoToken(afterEvents.length ? afterEvents[afterEvents.length - 1] : ev),
|
|
946
|
+
state: [...state.values()].map((e) => toClientEvent(e))
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}));
|
|
950
|
+
r.client("PUT", "/rooms/:roomId/redact/:eventId/:txnId", procedure({
|
|
951
|
+
auth: "user",
|
|
952
|
+
handler: async (ctx) => {
|
|
953
|
+
const s = ctx.session;
|
|
954
|
+
const body = await ctx.json();
|
|
955
|
+
const roomId = ctx.params.roomId;
|
|
956
|
+
await requireRoom(ctx.hs, roomId);
|
|
957
|
+
if (!await ownEvent(ctx.hs, s.userId, ctx.params.eventId)) await assertNotSuspended(ctx.hs, s, roomId);
|
|
958
|
+
await ctx.hs.rooms.checkRedactionAllowed(roomId, s.userId, ctx.params.eventId);
|
|
959
|
+
const content = {};
|
|
960
|
+
if (typeof body.reason === "string") content.reason = body.reason;
|
|
961
|
+
return { event_id: await ctx.hs.rooms.sendEvent(roomId, s.userId, {
|
|
962
|
+
type: M.ROOM.REDACTION,
|
|
963
|
+
content,
|
|
964
|
+
redacts: ctx.params.eventId
|
|
965
|
+
}, {
|
|
966
|
+
txnId: ctx.params.txnId,
|
|
967
|
+
deviceId: s.deviceId
|
|
968
|
+
}) };
|
|
969
|
+
}
|
|
970
|
+
}));
|
|
971
|
+
r.client("PUT", "/rooms/:roomId/typing/:userId", procedure({
|
|
972
|
+
auth: "user",
|
|
973
|
+
handler: async (ctx) => {
|
|
974
|
+
const s = ctx.session;
|
|
975
|
+
if (ctx.params.userId !== s.userId) throw errors.forbidden("Cannot set typing for another user");
|
|
976
|
+
const body = await ctx.json();
|
|
977
|
+
if (typeof body.typing !== "boolean") throw errors.badJson("typing must be a boolean");
|
|
978
|
+
await ctx.hs.rooms.requireJoined(ctx.params.roomId, s.userId);
|
|
979
|
+
await ctx.hs.typing.setTyping(ctx.params.roomId, s.userId, body.typing, Number(body.timeout) || 3e4);
|
|
980
|
+
return {};
|
|
981
|
+
}
|
|
982
|
+
}));
|
|
983
|
+
const RECEIPT_TYPES = [
|
|
984
|
+
M.READ._,
|
|
985
|
+
M.READ.PRIVATE,
|
|
986
|
+
M.FULLY_READ
|
|
987
|
+
];
|
|
988
|
+
const setReceipt = async (hs, s, roomId, type, eventId, threadId) => {
|
|
989
|
+
if (type === M.FULLY_READ) {
|
|
990
|
+
await hs.streams.write((stream) => hs.db.upsert("account_data", {
|
|
991
|
+
user_id: s.userId,
|
|
992
|
+
room_id: roomId,
|
|
993
|
+
type: M.FULLY_READ,
|
|
994
|
+
content: { event_id: eventId },
|
|
995
|
+
stream
|
|
996
|
+
}));
|
|
997
|
+
hs.notifier.notify([s.userId]);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const ev = await hs.rooms.getEvent(eventId);
|
|
1001
|
+
if (!ev || ev.pdu.room_id !== roomId) throw errors.notFound("Event not found");
|
|
1002
|
+
await hs.streams.write((stream) => hs.db.upsert("receipts", {
|
|
1003
|
+
room_id: roomId,
|
|
1004
|
+
user_id: s.userId,
|
|
1005
|
+
receipt_type: type,
|
|
1006
|
+
thread_id: threadId ?? "",
|
|
1007
|
+
event_id: eventId,
|
|
1008
|
+
ts: Date.now(),
|
|
1009
|
+
stream
|
|
1010
|
+
}));
|
|
1011
|
+
hs.notifier.notify(type === M.READ.PRIVATE ? [s.userId] : await hs.rooms.joinedMembers(roomId));
|
|
1012
|
+
await hs.federation.onReceipt(roomId, s.userId, type, eventId, Date.now(), threadId ?? "").catch(() => {});
|
|
1013
|
+
};
|
|
1014
|
+
const receiptBody = body({ thread_id: string(fail("M_INVALID_PARAM", "Invalid thread_id")).min(1, "M_INVALID_PARAM: Invalid thread_id").optional() });
|
|
1015
|
+
r.client("POST", "/rooms/:roomId/receipt/:receiptType/:eventId", procedure({
|
|
1016
|
+
auth: "user",
|
|
1017
|
+
input: receiptBody,
|
|
1018
|
+
handler: async (ctx) => {
|
|
1019
|
+
const s = ctx.session;
|
|
1020
|
+
if (!RECEIPT_TYPES.includes(ctx.params.receiptType)) throw errors.invalidParam("Unknown receipt type");
|
|
1021
|
+
const body = ctx.input;
|
|
1022
|
+
await ctx.hs.rooms.requireJoined(ctx.params.roomId, s.userId);
|
|
1023
|
+
await setReceipt(ctx.hs, s, ctx.params.roomId, ctx.params.receiptType, ctx.params.eventId, body.thread_id);
|
|
1024
|
+
return {};
|
|
1025
|
+
}
|
|
1026
|
+
}));
|
|
1027
|
+
r.client("POST", "/rooms/:roomId/read_markers", procedure({
|
|
1028
|
+
auth: "user",
|
|
1029
|
+
input: body({}),
|
|
1030
|
+
handler: async (ctx) => {
|
|
1031
|
+
const s = ctx.session;
|
|
1032
|
+
const body = ctx.input;
|
|
1033
|
+
await ctx.hs.rooms.requireJoined(ctx.params.roomId, s.userId);
|
|
1034
|
+
for (const type of RECEIPT_TYPES) {
|
|
1035
|
+
const eventId = body[type];
|
|
1036
|
+
if (typeof eventId === "string") await setReceipt(ctx.hs, s, ctx.params.roomId, type, eventId);
|
|
1037
|
+
}
|
|
1038
|
+
return {};
|
|
1039
|
+
}
|
|
1040
|
+
}));
|
|
1041
|
+
r.client("GET", "/directory/room/:alias", procedure({ handler: async (ctx) => {
|
|
1042
|
+
if (!isValidAlias(ctx.params.alias)) throw errors.invalidParam("Invalid alias");
|
|
1043
|
+
const { roomId, servers } = await ctx.hs.rooms.resolveRoom(ctx.params.alias);
|
|
1044
|
+
const inRoom = await ctx.hs.rooms.exists(roomId) ? [...await ctx.hs.federation.roomDestinations(roomId, await ctx.hs.rooms.currentState(roomId))] : [];
|
|
1045
|
+
return {
|
|
1046
|
+
room_id: roomId,
|
|
1047
|
+
servers: [.../* @__PURE__ */ new Set([...servers, ...inRoom])]
|
|
1048
|
+
};
|
|
1049
|
+
} }));
|
|
1050
|
+
r.client("PUT", "/directory/room/:alias", procedure({
|
|
1051
|
+
auth: "user",
|
|
1052
|
+
handler: async (ctx) => {
|
|
1053
|
+
const s = ctx.session;
|
|
1054
|
+
const body = await ctx.json();
|
|
1055
|
+
const alias = ctx.params.alias;
|
|
1056
|
+
if (!isValidAlias(alias) || /\s/.test(alias)) throw errors.invalidParam("Invalid alias");
|
|
1057
|
+
if (parseId(alias, "#").server !== ctx.hs.serverName) throw errors.invalidParam("Alias must be on this server");
|
|
1058
|
+
if (!isValidRoomId(body.room_id)) throw errors.invalidParam("Invalid room_id");
|
|
1059
|
+
if (!await ctx.hs.rooms.exists(body.room_id)) throw errors.notFound("Room not found");
|
|
1060
|
+
const owner = ctx.hs.appservices.exclusiveAliasOwner(alias);
|
|
1061
|
+
if (owner && s.appservice !== owner) throw errors.exclusive("This alias is reserved by an application service");
|
|
1062
|
+
if (await ctx.hs.db.findOne("room_aliases", [{
|
|
1063
|
+
field: "alias",
|
|
1064
|
+
value: alias
|
|
1065
|
+
}])) throw new MatrixError(409, "M_UNKNOWN", "Room alias already exists");
|
|
1066
|
+
await ctx.hs.db.create("room_aliases", {
|
|
1067
|
+
alias,
|
|
1068
|
+
room_id: body.room_id,
|
|
1069
|
+
creator: s.userId
|
|
1070
|
+
});
|
|
1071
|
+
return {};
|
|
1072
|
+
}
|
|
1073
|
+
}));
|
|
1074
|
+
r.client("DELETE", "/directory/room/:alias", procedure({
|
|
1075
|
+
auth: "user",
|
|
1076
|
+
handler: async (ctx) => {
|
|
1077
|
+
const s = ctx.session;
|
|
1078
|
+
const row = await ctx.hs.db.findOne("room_aliases", [{
|
|
1079
|
+
field: "alias",
|
|
1080
|
+
value: ctx.params.alias
|
|
1081
|
+
}]);
|
|
1082
|
+
if (!row) throw errors.notFound("Room alias not found");
|
|
1083
|
+
if (!await canDeleteAlias(ctx.hs, s, row)) throw errors.forbidden("You may not delete this alias");
|
|
1084
|
+
await ctx.hs.db.delete("room_aliases", [{
|
|
1085
|
+
field: "alias",
|
|
1086
|
+
value: ctx.params.alias
|
|
1087
|
+
}]);
|
|
1088
|
+
const canonical = getState(await ctx.hs.rooms.currentState(row.room_id), M.ROOM.CANONICAL_ALIAS);
|
|
1089
|
+
if (canonical) {
|
|
1090
|
+
const content = { ...canonical.content };
|
|
1091
|
+
let changed = false;
|
|
1092
|
+
if (content.alias === ctx.params.alias) {
|
|
1093
|
+
delete content.alias;
|
|
1094
|
+
changed = true;
|
|
1095
|
+
}
|
|
1096
|
+
if (Array.isArray(content.alt_aliases) && content.alt_aliases.includes(ctx.params.alias)) {
|
|
1097
|
+
content.alt_aliases = content.alt_aliases.filter((a) => a !== ctx.params.alias);
|
|
1098
|
+
changed = true;
|
|
1099
|
+
}
|
|
1100
|
+
if (changed) await ctx.hs.rooms.sendEvent(row.room_id, s.userId, {
|
|
1101
|
+
type: M.ROOM.CANONICAL_ALIAS,
|
|
1102
|
+
state_key: "",
|
|
1103
|
+
content
|
|
1104
|
+
}).catch(() => {});
|
|
1105
|
+
}
|
|
1106
|
+
return {};
|
|
1107
|
+
}
|
|
1108
|
+
}));
|
|
1109
|
+
r.client("GET", "/rooms/:roomId/aliases", procedure({
|
|
1110
|
+
auth: "user",
|
|
1111
|
+
handler: async (ctx) => {
|
|
1112
|
+
const s = ctx.session;
|
|
1113
|
+
await visibleState(ctx.hs, ctx.params.roomId, s.userId);
|
|
1114
|
+
return { aliases: (await ctx.hs.db.findMany("room_aliases", { where: [{
|
|
1115
|
+
field: "room_id",
|
|
1116
|
+
value: ctx.params.roomId
|
|
1117
|
+
}] })).map((r) => r.alias) };
|
|
1118
|
+
}
|
|
1119
|
+
}));
|
|
1120
|
+
r.client("GET", "/directory/list/room/:roomId", procedure({ handler: async (ctx) => {
|
|
1121
|
+
const row = await ctx.hs.db.findOne("rooms", [{
|
|
1122
|
+
field: "room_id",
|
|
1123
|
+
value: ctx.params.roomId
|
|
1124
|
+
}]);
|
|
1125
|
+
if (!row) throw errors.notFound("Room not found");
|
|
1126
|
+
return { visibility: row.is_public ? "public" : "private" };
|
|
1127
|
+
} }));
|
|
1128
|
+
r.client("PUT", "/directory/list/room/:roomId", procedure({
|
|
1129
|
+
auth: "user",
|
|
1130
|
+
handler: async (ctx) => {
|
|
1131
|
+
const s = ctx.session;
|
|
1132
|
+
const body = await ctx.json();
|
|
1133
|
+
if (!["public", "private"].includes(body.visibility)) throw errors.invalidParam("Invalid visibility");
|
|
1134
|
+
const version = await ctx.hs.rooms.roomVersion(ctx.params.roomId);
|
|
1135
|
+
if (!version) throw errors.notFound("Room not found");
|
|
1136
|
+
const state = await ctx.hs.rooms.requireJoined(ctx.params.roomId, s.userId);
|
|
1137
|
+
const pl = powerLevels(state, version);
|
|
1138
|
+
if (!s.isAdmin && userLevel(pl, s.userId) < (pl.events[M.ROOM.CANONICAL_ALIAS] ?? pl.state_default)) throw errors.forbidden("Insufficient power level");
|
|
1139
|
+
await ctx.hs.db.update("rooms", [{
|
|
1140
|
+
field: "room_id",
|
|
1141
|
+
value: ctx.params.roomId
|
|
1142
|
+
}], { is_public: body.visibility === "public" });
|
|
1143
|
+
return {};
|
|
1144
|
+
}
|
|
1145
|
+
}));
|
|
1146
|
+
const remoteOrLocal = async (ctx, body) => {
|
|
1147
|
+
const server = ctx.query.get("server");
|
|
1148
|
+
if (server && server !== ctx.hs.serverName) {
|
|
1149
|
+
const query = { ...body };
|
|
1150
|
+
if (ctx.query.get("limit")) query.limit = Number(ctx.query.get("limit"));
|
|
1151
|
+
if (ctx.query.get("since")) query.since = ctx.query.get("since");
|
|
1152
|
+
return ctx.hs.federation.queryPublicRooms(server, query);
|
|
1153
|
+
}
|
|
1154
|
+
return publicRooms(ctx, body);
|
|
1155
|
+
};
|
|
1156
|
+
r.client("GET", "/publicRooms", procedure({ handler: (ctx) => remoteOrLocal(ctx, {}) }));
|
|
1157
|
+
r.client("POST", "/publicRooms", procedure({
|
|
1158
|
+
auth: "user",
|
|
1159
|
+
input: body({}),
|
|
1160
|
+
handler: async (ctx) => {
|
|
1161
|
+
return remoteOrLocal(ctx, ctx.input);
|
|
1162
|
+
}
|
|
1163
|
+
}));
|
|
1164
|
+
const upgradeBody = body({
|
|
1165
|
+
new_version: string(fail("M_BAD_JSON", "Missing new_version")),
|
|
1166
|
+
additional_creators: unknown().optional()
|
|
1167
|
+
});
|
|
1168
|
+
r.client("POST", "/rooms/:roomId/upgrade", procedure({
|
|
1169
|
+
auth: "user",
|
|
1170
|
+
input: upgradeBody,
|
|
1171
|
+
handler: async (ctx) => {
|
|
1172
|
+
const s = ctx.session;
|
|
1173
|
+
const body = ctx.input;
|
|
1174
|
+
return { replacement_room: await upgradeRoom(ctx.hs, s, ctx.params.roomId, body.new_version, body.additional_creators) };
|
|
1175
|
+
}
|
|
1176
|
+
}));
|
|
1177
|
+
const reportBody = body({
|
|
1178
|
+
score: number(fail("M_INVALID_PARAM", "score must be between -100 and 0")).int("M_INVALID_PARAM: score must be between -100 and 0").min(-100, "M_INVALID_PARAM: score must be between -100 and 0").max(0, "M_INVALID_PARAM: score must be between -100 and 0").optional(),
|
|
1179
|
+
reason: string(fail("M_BAD_JSON", "reason must be a string")).optional()
|
|
1180
|
+
});
|
|
1181
|
+
const report = async (ctx) => {
|
|
1182
|
+
const s = ctx.session;
|
|
1183
|
+
const hs = ctx.hs;
|
|
1184
|
+
const roomId = ctx.params.roomId;
|
|
1185
|
+
await requireRoom(hs, roomId);
|
|
1186
|
+
if (ctx.params.eventId) {
|
|
1187
|
+
const ev = await hs.rooms.getEvent(ctx.params.eventId);
|
|
1188
|
+
if (!ev || ev.pdu.room_id !== roomId || !(await hs.rooms.filterVisible(s.userId, roomId, [ev])).length) throw errors.notFound("Event not found");
|
|
1189
|
+
} else if (await hs.rooms.membership(roomId, s.userId) !== "join") throw errors.notFound("Room not found");
|
|
1190
|
+
const body = ctx.input;
|
|
1191
|
+
const id = await hs.cluster.nextId("event_reports");
|
|
1192
|
+
await hs.db.create("event_reports", {
|
|
1193
|
+
id,
|
|
1194
|
+
ts: Date.now(),
|
|
1195
|
+
room_id: roomId,
|
|
1196
|
+
event_id: ctx.params.eventId ?? null,
|
|
1197
|
+
reporter: s.userId,
|
|
1198
|
+
reason: body.reason ?? null,
|
|
1199
|
+
score: body.score ?? null,
|
|
1200
|
+
resolved: false
|
|
1201
|
+
});
|
|
1202
|
+
await hs.audit.record({
|
|
1203
|
+
action: "event_report",
|
|
1204
|
+
actor: s.userId,
|
|
1205
|
+
target: ctx.params.eventId ?? roomId,
|
|
1206
|
+
details: {
|
|
1207
|
+
room_id: roomId,
|
|
1208
|
+
report_id: id
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
if (hs.adminRoom) hs.adminRoom.say(`New report #${id} from ${s.userId} about ${ctx.params.eventId ?? "room"} in ${roomId}: ${body.reason ?? "(no reason)"}`).catch(() => {});
|
|
1212
|
+
return {};
|
|
1213
|
+
};
|
|
1214
|
+
r.client("POST", "/users/:userId/report", procedure({
|
|
1215
|
+
auth: "user",
|
|
1216
|
+
input: body({ reason: string(fail("M_BAD_JSON", "reason must be a string")) }),
|
|
1217
|
+
handler: async (ctx) => {
|
|
1218
|
+
const s = ctx.session;
|
|
1219
|
+
const hs = ctx.hs;
|
|
1220
|
+
const body = ctx.input;
|
|
1221
|
+
const userId = ctx.params.userId;
|
|
1222
|
+
if (!isValidUserId(userId)) throw errors.invalidParam("Invalid user ID");
|
|
1223
|
+
if (hs.isLocalUser(userId) && !await hs.db.findOne("users", [{
|
|
1224
|
+
field: "user_id",
|
|
1225
|
+
value: userId
|
|
1226
|
+
}])) throw errors.notFound("Unknown user");
|
|
1227
|
+
const id = await hs.cluster.nextId("user_reports");
|
|
1228
|
+
await hs.db.create("user_reports", {
|
|
1229
|
+
id,
|
|
1230
|
+
ts: Date.now(),
|
|
1231
|
+
user_id: userId,
|
|
1232
|
+
reporter: s.userId,
|
|
1233
|
+
reason: body.reason,
|
|
1234
|
+
resolved: false
|
|
1235
|
+
});
|
|
1236
|
+
await hs.audit.record({
|
|
1237
|
+
action: "user_report",
|
|
1238
|
+
actor: s.userId,
|
|
1239
|
+
target: userId,
|
|
1240
|
+
details: { report_id: id }
|
|
1241
|
+
});
|
|
1242
|
+
if (hs.adminRoom) hs.adminRoom.say(`New user report #${id} from ${s.userId} about ${userId}: ${body.reason || "(no reason)"}`).catch(() => {});
|
|
1243
|
+
return {};
|
|
1244
|
+
}
|
|
1245
|
+
}));
|
|
1246
|
+
r.client("GET", "/mutual_rooms", procedure({
|
|
1247
|
+
auth: "user",
|
|
1248
|
+
handler: async (ctx) => {
|
|
1249
|
+
const s = ctx.session;
|
|
1250
|
+
const other = ctx.query.get("user_id");
|
|
1251
|
+
if (other === null) throw errors.missingParam("Missing user_id");
|
|
1252
|
+
if (!isValidUserId(other)) throw errors.invalidParam("Invalid user_id");
|
|
1253
|
+
if (other === s.userId) throw errors.invalidParam("user_id must not be your own");
|
|
1254
|
+
const theirs = new Set(await ctx.hs.rooms.roomsForUser(other));
|
|
1255
|
+
const joined = (await ctx.hs.rooms.roomsForUser(s.userId)).filter((r) => theirs.has(r)).sort();
|
|
1256
|
+
const from = ctx.query.get("from");
|
|
1257
|
+
const start = from === null ? 0 : joined.findIndex((r) => r > from);
|
|
1258
|
+
if (from !== null && !isValidRoomId(from)) throw errors.invalidParam("Invalid from token");
|
|
1259
|
+
const page = start < 0 ? [] : joined.slice(start, start + 100);
|
|
1260
|
+
const out = {
|
|
1261
|
+
joined: page,
|
|
1262
|
+
count: joined.length
|
|
1263
|
+
};
|
|
1264
|
+
if (start >= 0 && start + 100 < joined.length) out.next_batch = page[page.length - 1];
|
|
1265
|
+
return out;
|
|
1266
|
+
}
|
|
1267
|
+
}), ["v1"]);
|
|
1268
|
+
r.client("POST", "/rooms/:roomId/report/:eventId", procedure({
|
|
1269
|
+
auth: "user",
|
|
1270
|
+
input: reportBody,
|
|
1271
|
+
handler: report
|
|
1272
|
+
}));
|
|
1273
|
+
r.client("POST", "/rooms/:roomId/report", procedure({
|
|
1274
|
+
auth: "user",
|
|
1275
|
+
input: reportBody,
|
|
1276
|
+
handler: report
|
|
1277
|
+
}));
|
|
1278
|
+
r.client("GET", "/admin/whois/:userId", procedure({
|
|
1279
|
+
auth: "user",
|
|
1280
|
+
handler: async (ctx) => {
|
|
1281
|
+
const s = ctx.session;
|
|
1282
|
+
const userId = ctx.params.userId;
|
|
1283
|
+
if (userId !== s.userId && !s.isAdmin) throw errors.forbidden("Only server admins can look up other users");
|
|
1284
|
+
if (!ctx.hs.isLocalUser(userId)) throw errors.notFound("Unknown user");
|
|
1285
|
+
const devices = await ctx.hs.db.findMany("devices", { where: [{
|
|
1286
|
+
field: "user_id",
|
|
1287
|
+
value: userId
|
|
1288
|
+
}] });
|
|
1289
|
+
const out = {};
|
|
1290
|
+
for (const d of devices) out[d.device_id] = { sessions: [{ connections: d.last_seen_ip ? [{
|
|
1291
|
+
ip: d.last_seen_ip,
|
|
1292
|
+
last_seen: d.last_seen_ts
|
|
1293
|
+
}] : [] }] };
|
|
1294
|
+
return {
|
|
1295
|
+
user_id: userId,
|
|
1296
|
+
devices: out
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
}));
|
|
1300
|
+
r.client("PUT", "/directory/list/appservice/:networkId/:roomId", procedure({
|
|
1301
|
+
auth: "user",
|
|
1302
|
+
handler: async (ctx) => {
|
|
1303
|
+
if (!ctx.session.appservice) throw errors.forbidden("Only application services may use this endpoint");
|
|
1304
|
+
const body = await ctx.json();
|
|
1305
|
+
if (!["public", "private"].includes(body.visibility)) throw errors.invalidParam("Invalid visibility");
|
|
1306
|
+
if (!await ctx.hs.rooms.exists(ctx.params.roomId)) throw errors.notFound("Room not found");
|
|
1307
|
+
await ctx.hs.db.update("rooms", [{
|
|
1308
|
+
field: "room_id",
|
|
1309
|
+
value: ctx.params.roomId
|
|
1310
|
+
}], { is_public: body.visibility === "public" });
|
|
1311
|
+
return {};
|
|
1312
|
+
}
|
|
1313
|
+
}));
|
|
1314
|
+
const relVersions = ["v1", "v3"];
|
|
1315
|
+
r.client("GET", "/rooms/:roomId/relations/:eventId", procedure({ handler: relations }), relVersions);
|
|
1316
|
+
r.client("GET", "/rooms/:roomId/relations/:eventId/:relType", procedure({ handler: relations }), relVersions);
|
|
1317
|
+
r.client("GET", "/rooms/:roomId/relations/:eventId/:relType/:eventType", procedure({ handler: relations }), relVersions);
|
|
1318
|
+
r.client("GET", "/rooms/:roomId/threads", procedure({ handler: threads }), ["v1"]);
|
|
1319
|
+
r.client("GET", "/rooms/:roomId/timestamp_to_event", procedure({
|
|
1320
|
+
auth: "user",
|
|
1321
|
+
handler: async (ctx) => {
|
|
1322
|
+
const s = ctx.session;
|
|
1323
|
+
const hs = ctx.hs;
|
|
1324
|
+
const roomId = ctx.params.roomId;
|
|
1325
|
+
await visibleState(hs, roomId, s.userId);
|
|
1326
|
+
const ts = Number(ctx.query.get("ts"));
|
|
1327
|
+
const dir = ctx.query.get("dir");
|
|
1328
|
+
if (!Number.isFinite(ts) || dir !== "f" && dir !== "b") throw errors.invalidParam("Invalid ts or dir");
|
|
1329
|
+
let local = null;
|
|
1330
|
+
const rows = await hs.db.findMany("events", { where: [{
|
|
1331
|
+
field: "room_id",
|
|
1332
|
+
value: roomId
|
|
1333
|
+
}] });
|
|
1334
|
+
rows.sort((a, b) => dir === "f" ? a.json.origin_server_ts - b.json.origin_server_ts || a.depth - b.depth || a.stream - b.stream : b.json.origin_server_ts - a.json.origin_server_ts || b.depth - a.depth || b.stream - a.stream);
|
|
1335
|
+
for (const row of rows) {
|
|
1336
|
+
const t = row.json.origin_server_ts;
|
|
1337
|
+
if (!(dir === "f" ? t >= ts : t <= ts)) continue;
|
|
1338
|
+
if ((await hs.rooms.filterVisible(s.userId, roomId, [hs.rooms.rowToStored(row)])).length) {
|
|
1339
|
+
local = {
|
|
1340
|
+
event_id: row.event_id,
|
|
1341
|
+
origin_server_ts: t
|
|
1342
|
+
};
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
if (hs.federation.enabled) for (const server of hs.federation.serversInRoom(await hs.rooms.currentState(roomId))) try {
|
|
1347
|
+
const remote = await hs.federation.client.request(server, "GET", `/_matrix/federation/v1/timestamp_to_event/${encodeURIComponent(roomId)}`, {
|
|
1348
|
+
query: {
|
|
1349
|
+
ts: String(ts),
|
|
1350
|
+
dir
|
|
1351
|
+
},
|
|
1352
|
+
timeoutMs: 1e4
|
|
1353
|
+
});
|
|
1354
|
+
if (typeof remote.event_id !== "string" || typeof remote.origin_server_ts !== "number") continue;
|
|
1355
|
+
if (!local || (dir === "f" ? remote.origin_server_ts < local.origin_server_ts : remote.origin_server_ts > local.origin_server_ts)) {
|
|
1356
|
+
if (!await hs.rooms.getEvent(remote.event_id)) await hs.federation.backfill(roomId, [remote.event_id], 1).catch(() => 0);
|
|
1357
|
+
local = {
|
|
1358
|
+
event_id: remote.event_id,
|
|
1359
|
+
origin_server_ts: remote.origin_server_ts
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
break;
|
|
1363
|
+
} catch {}
|
|
1364
|
+
if (!local) throw errors.notFound("No event found");
|
|
1365
|
+
return local;
|
|
1366
|
+
}
|
|
1367
|
+
}), ["v1", "v3"]);
|
|
1368
|
+
}
|
|
1369
|
+
async function relations(ctx) {
|
|
1370
|
+
const s = await ctx.auth();
|
|
1371
|
+
const hs = ctx.hs;
|
|
1372
|
+
const roomId = ctx.params.roomId;
|
|
1373
|
+
await visibleState(hs, roomId, s.userId);
|
|
1374
|
+
const parent = await hs.rooms.getEvent(ctx.params.eventId);
|
|
1375
|
+
if (!parent || parent.pdu.room_id !== roomId) throw errors.notFound("Event not found");
|
|
1376
|
+
const dir = ctx.query.get("dir") === "f" ? "f" : "b";
|
|
1377
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "50", 10) || 50, 1), 1e3);
|
|
1378
|
+
const from = parseToken(ctx.query.get("from"));
|
|
1379
|
+
const rows = await hs.db.findMany("events", {
|
|
1380
|
+
where: [{
|
|
1381
|
+
field: "room_id",
|
|
1382
|
+
value: roomId
|
|
1383
|
+
}, ...from !== null ? [{
|
|
1384
|
+
field: "stream",
|
|
1385
|
+
operator: dir === "b" ? "lte" : "gt",
|
|
1386
|
+
value: from
|
|
1387
|
+
}] : []],
|
|
1388
|
+
sortBy: {
|
|
1389
|
+
field: "stream",
|
|
1390
|
+
direction: dir === "b" ? "desc" : "asc"
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
const matches = [];
|
|
1394
|
+
let next = null;
|
|
1395
|
+
for (const row of rows) {
|
|
1396
|
+
const rel = row.json.content?.[M.RELATES_TO];
|
|
1397
|
+
if (!isObject(rel) || rel.event_id !== ctx.params.eventId) continue;
|
|
1398
|
+
if (ctx.params.relType && rel.rel_type !== ctx.params.relType) continue;
|
|
1399
|
+
if (ctx.params.eventType && row.type !== ctx.params.eventType) continue;
|
|
1400
|
+
const stored = hs.rooms.rowToStored(row);
|
|
1401
|
+
if ((await hs.rooms.filterVisible(s.userId, roomId, [stored])).length === 0) continue;
|
|
1402
|
+
if (matches.length >= limit) {
|
|
1403
|
+
next = dir === "b" ? matches[matches.length - 1].stream - 1 : matches[matches.length - 1].stream;
|
|
1404
|
+
break;
|
|
1405
|
+
}
|
|
1406
|
+
matches.push(stored);
|
|
1407
|
+
}
|
|
1408
|
+
const out = { chunk: matches.map((e) => hs.rooms.formatEvent(e, {
|
|
1409
|
+
userId: s.userId,
|
|
1410
|
+
deviceId: s.deviceId
|
|
1411
|
+
})) };
|
|
1412
|
+
if (next !== null) out.next_batch = `s${next}`;
|
|
1413
|
+
return out;
|
|
1414
|
+
}
|
|
1415
|
+
/** `GET /rooms/{roomId}/threads`: thread roots, most recently active first. */
|
|
1416
|
+
async function threads(ctx) {
|
|
1417
|
+
const s = await ctx.auth();
|
|
1418
|
+
const hs = ctx.hs;
|
|
1419
|
+
const roomId = ctx.params.roomId;
|
|
1420
|
+
await visibleState(hs, roomId, s.userId);
|
|
1421
|
+
const include = ctx.query.get("include") === "participated" ? "participated" : "all";
|
|
1422
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "50", 10) || 50, 1), 1e3);
|
|
1423
|
+
const from = parseToken(ctx.query.get("from"));
|
|
1424
|
+
const rows = await hs.db.findMany("events", {
|
|
1425
|
+
where: [{
|
|
1426
|
+
field: "room_id",
|
|
1427
|
+
value: roomId
|
|
1428
|
+
}],
|
|
1429
|
+
sortBy: {
|
|
1430
|
+
field: "stream",
|
|
1431
|
+
direction: "desc"
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1435
|
+
for (const row of rows) {
|
|
1436
|
+
const rel = row.json.content?.[M.RELATES_TO];
|
|
1437
|
+
if (!isObject(rel) || rel.rel_type !== M.THREAD || typeof rel.event_id !== "string") continue;
|
|
1438
|
+
const entry = latest.get(rel.event_id) ?? {
|
|
1439
|
+
stream: row.stream,
|
|
1440
|
+
latestId: row.event_id,
|
|
1441
|
+
count: 0,
|
|
1442
|
+
participants: /* @__PURE__ */ new Set()
|
|
1443
|
+
};
|
|
1444
|
+
entry.participants.add(row.sender);
|
|
1445
|
+
entry.count++;
|
|
1446
|
+
latest.set(rel.event_id, entry);
|
|
1447
|
+
}
|
|
1448
|
+
const roots = [];
|
|
1449
|
+
for (const [rootId, info] of latest) {
|
|
1450
|
+
const root = await hs.rooms.getEvent(rootId);
|
|
1451
|
+
if (!root || root.pdu.room_id !== roomId) continue;
|
|
1452
|
+
if (include === "participated" && root.pdu.sender !== s.userId && !info.participants.has(s.userId)) continue;
|
|
1453
|
+
if ((await hs.rooms.filterVisible(s.userId, roomId, [root])).length === 0) continue;
|
|
1454
|
+
roots.push({
|
|
1455
|
+
ev: root,
|
|
1456
|
+
stream: info.stream
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
roots.sort((a, b) => b.stream - a.stream);
|
|
1460
|
+
const page = roots.filter((r) => from === null || r.stream <= from);
|
|
1461
|
+
const chunk = page.slice(0, limit);
|
|
1462
|
+
const viewer = {
|
|
1463
|
+
userId: s.userId,
|
|
1464
|
+
deviceId: s.deviceId
|
|
1465
|
+
};
|
|
1466
|
+
const out = { chunk: await Promise.all(chunk.map(async (r) => {
|
|
1467
|
+
const ev = hs.rooms.formatEvent(r.ev, viewer);
|
|
1468
|
+
const info = latest.get(r.ev.pdu.event_id);
|
|
1469
|
+
const latestEv = await hs.rooms.getEvent(info.latestId);
|
|
1470
|
+
ev.unsigned = {
|
|
1471
|
+
...ev.unsigned,
|
|
1472
|
+
[M.RELATIONS]: {
|
|
1473
|
+
...ev.unsigned?.[M.RELATIONS],
|
|
1474
|
+
[M.THREAD]: {
|
|
1475
|
+
latest_event: latestEv ? hs.rooms.formatEvent(latestEv, viewer) : void 0,
|
|
1476
|
+
count: info.count,
|
|
1477
|
+
current_user_participated: r.ev.pdu.sender === s.userId || info.participants.has(s.userId)
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
return ev;
|
|
1482
|
+
})) };
|
|
1483
|
+
if (page.length > limit) out.next_batch = `s${page[limit].stream}`;
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
export { paginate, parsePageToken, parseToken, registerRoomRoutes, topoToken, visibleState };
|