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,88 @@
|
|
|
1
|
+
import { isObject } from "../core/json.js";
|
|
2
|
+
import { M, UNSTABLE } from "../core/m.js";
|
|
3
|
+
import { errors } from "../core/errors.js";
|
|
4
|
+
import { globMatch as globMatch$1 } from "../core/glob.js";
|
|
5
|
+
//#region src/api/filters.ts
|
|
6
|
+
function globMatch(pattern, value) {
|
|
7
|
+
return globMatch$1(pattern, value, { questionMark: false });
|
|
8
|
+
}
|
|
9
|
+
function matchesFilter(filter, ev) {
|
|
10
|
+
if (!filter) return true;
|
|
11
|
+
if (filter.not_types?.some((t) => globMatch(t, ev.type))) return false;
|
|
12
|
+
if (filter.types && !filter.types.some((t) => globMatch(t, ev.type))) return false;
|
|
13
|
+
if (ev.sender !== void 0) {
|
|
14
|
+
if (filter.not_senders?.includes(ev.sender)) return false;
|
|
15
|
+
if (filter.senders && !filter.senders.includes(ev.sender)) return false;
|
|
16
|
+
}
|
|
17
|
+
if (ev.room_id !== void 0) {
|
|
18
|
+
if (filter.not_rooms?.includes(ev.room_id)) return false;
|
|
19
|
+
if (filter.rooms && !filter.rooms.includes(ev.room_id)) return false;
|
|
20
|
+
}
|
|
21
|
+
const relTypes = filter.rel_types ?? filter[UNSTABLE.ORG.MATRIX.MSC3874.REL_TYPES];
|
|
22
|
+
const notRelTypes = filter.not_rel_types ?? filter[UNSTABLE.ORG.MATRIX.MSC3874.NOT_REL_TYPES];
|
|
23
|
+
if (relTypes || notRelTypes) {
|
|
24
|
+
const relType = ev.content?.[M.RELATES_TO]?.rel_type;
|
|
25
|
+
if (relTypes && !relTypes.includes(relType)) return false;
|
|
26
|
+
if (notRelTypes && typeof relType === "string" && notRelTypes.includes(relType)) return false;
|
|
27
|
+
}
|
|
28
|
+
if (filter.contains_url !== void 0) {
|
|
29
|
+
if (typeof ev.content?.url === "string" !== filter.contains_url) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
function roomAllowed(filter, roomId) {
|
|
34
|
+
const r = filter.room;
|
|
35
|
+
if (!r) return true;
|
|
36
|
+
if (r.not_rooms?.includes(roomId)) return false;
|
|
37
|
+
if (r.rooms && !r.rooms.includes(roomId)) return false;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function validateEventFilter(f, name) {
|
|
41
|
+
if (f === void 0) return;
|
|
42
|
+
if (!isObject(f)) throw errors.badJson(`${name} must be an object`);
|
|
43
|
+
for (const key of [
|
|
44
|
+
"types",
|
|
45
|
+
"not_types",
|
|
46
|
+
"senders",
|
|
47
|
+
"not_senders",
|
|
48
|
+
"rooms",
|
|
49
|
+
"not_rooms"
|
|
50
|
+
]) if (f[key] !== void 0 && (!Array.isArray(f[key]) || !f[key].every((x) => typeof x === "string"))) throw errors.badJson(`${name}.${key} must be a list of strings`);
|
|
51
|
+
if (f.rooms?.some((r) => !r.startsWith("!")) || f.not_rooms?.some((r) => !r.startsWith("!"))) throw errors.badJson(`${name}: rooms must be room IDs`);
|
|
52
|
+
if (f.senders?.some((u) => !u.startsWith("@")) || f.not_senders?.some((u) => !u.startsWith("@"))) throw errors.badJson(`${name}: senders must be user IDs`);
|
|
53
|
+
if (f.limit !== void 0 && (!Number.isInteger(f.limit) || f.limit < 0)) throw errors.badJson(`${name}.limit must be a non-negative integer`);
|
|
54
|
+
}
|
|
55
|
+
function validateSyncFilter(f) {
|
|
56
|
+
if (!isObject(f)) throw errors.badJson("Filter must be an object");
|
|
57
|
+
for (const k of ["presence", "account_data"]) validateEventFilter(f[k], k);
|
|
58
|
+
if (f.room !== void 0) {
|
|
59
|
+
if (!isObject(f.room)) throw errors.badJson("room must be an object");
|
|
60
|
+
for (const k of [
|
|
61
|
+
"timeline",
|
|
62
|
+
"state",
|
|
63
|
+
"ephemeral",
|
|
64
|
+
"account_data"
|
|
65
|
+
]) validateEventFilter(f.room[k], `room.${k}`);
|
|
66
|
+
validateEventFilter({
|
|
67
|
+
rooms: f.room.rooms,
|
|
68
|
+
not_rooms: f.room.not_rooms
|
|
69
|
+
}, "room");
|
|
70
|
+
}
|
|
71
|
+
if (f.event_format !== void 0 && !["client", "federation"].includes(f.event_format)) throw errors.badJson("Invalid event_format");
|
|
72
|
+
return f;
|
|
73
|
+
}
|
|
74
|
+
/** Parse the `filter` query param of /messages and /context (a JSON RoomEventFilter). */
|
|
75
|
+
function parseRoomEventFilter(ctx) {
|
|
76
|
+
const raw = ctx.query.get("filter");
|
|
77
|
+
if (!raw) return void 0;
|
|
78
|
+
let parsed;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
throw errors.badJson("Invalid filter JSON");
|
|
83
|
+
}
|
|
84
|
+
validateEventFilter(parsed, "filter");
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { matchesFilter, parseRoomEventFilter, roomAllowed, validateSyncFilter };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { registerThreadSubscriptionRoutes } from "./threadsubs.js";
|
|
2
|
+
import { registerAccountRoutes } from "./account.js";
|
|
3
|
+
import { registerBackupRoutes } from "./backup.js";
|
|
4
|
+
import { registerRoomRoutes } from "./rooms.js";
|
|
5
|
+
import { registerSearchRoutes } from "./search.js";
|
|
6
|
+
import { registerPreviewRoutes } from "./preview.js";
|
|
7
|
+
import { registerSpaceRoutes } from "./spaces.js";
|
|
8
|
+
import { registerAdminRoutes } from "./admin.js";
|
|
9
|
+
import { registerSyncRoutes } from "./sync.js";
|
|
10
|
+
import { registerSlidingSyncRoutes } from "./slidingsync.js";
|
|
11
|
+
import { registerRelationshipRoutes } from "./relationships.js";
|
|
12
|
+
import { registerFederationRoutes } from "./federation.js";
|
|
13
|
+
import { registerRtcRoutes } from "./rtc.js";
|
|
14
|
+
import { registerAppServiceRoutes } from "./appservice.js";
|
|
15
|
+
import { registerMediaRoutes } from "./media.js";
|
|
16
|
+
import { registerLegacyRoutes } from "./legacy.js";
|
|
17
|
+
import { registerThreepidRoutes } from "./threepid.js";
|
|
18
|
+
import { registerMiscRoutes } from "./misc.js";
|
|
19
|
+
import { registerProfileRoutes } from "./profile.js";
|
|
20
|
+
import { registerPushRoutes } from "./push.js";
|
|
21
|
+
import { registerUserRoutes } from "./user.js";
|
|
22
|
+
//#region src/api/index.ts
|
|
23
|
+
function registerRoutes(r) {
|
|
24
|
+
registerMiscRoutes(r);
|
|
25
|
+
registerAccountRoutes(r);
|
|
26
|
+
registerProfileRoutes(r);
|
|
27
|
+
registerRoomRoutes(r);
|
|
28
|
+
registerLegacyRoutes(r);
|
|
29
|
+
registerThreepidRoutes(r);
|
|
30
|
+
registerSyncRoutes(r);
|
|
31
|
+
registerUserRoutes(r);
|
|
32
|
+
registerPushRoutes(r);
|
|
33
|
+
registerMediaRoutes(r);
|
|
34
|
+
registerSearchRoutes(r);
|
|
35
|
+
registerBackupRoutes(r);
|
|
36
|
+
registerPreviewRoutes(r);
|
|
37
|
+
registerSpaceRoutes(r);
|
|
38
|
+
registerAdminRoutes(r);
|
|
39
|
+
registerSlidingSyncRoutes(r);
|
|
40
|
+
registerFederationRoutes(r);
|
|
41
|
+
registerRtcRoutes(r);
|
|
42
|
+
registerAppServiceRoutes(r);
|
|
43
|
+
registerThreadSubscriptionRoutes(r);
|
|
44
|
+
registerRelationshipRoutes(r);
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { registerRoutes };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { toClientEvent } from "../core/events.js";
|
|
2
|
+
import { errors } from "../core/errors.js";
|
|
3
|
+
import { isValidRoomId } from "../core/ids.js";
|
|
4
|
+
import { procedure } from "../http/procedure.js";
|
|
5
|
+
import { paginate, visibleState } from "./rooms.js";
|
|
6
|
+
//#region src/api/legacy.ts
|
|
7
|
+
const viewerOf = (s) => ({
|
|
8
|
+
userId: s.userId,
|
|
9
|
+
deviceId: s.deviceId
|
|
10
|
+
});
|
|
11
|
+
const parseStreamToken = (raw, fallback) => {
|
|
12
|
+
if (raw === null) return fallback;
|
|
13
|
+
const m = /^s(\d+)/.exec(raw);
|
|
14
|
+
if (!m) throw errors.invalidParam("Invalid from token");
|
|
15
|
+
return Number(m[1]);
|
|
16
|
+
};
|
|
17
|
+
async function roomAccountData(hs, userId, roomId) {
|
|
18
|
+
return (await hs.db.findMany("account_data", { where: [{
|
|
19
|
+
field: "user_id",
|
|
20
|
+
value: userId
|
|
21
|
+
}, {
|
|
22
|
+
field: "room_id",
|
|
23
|
+
value: roomId
|
|
24
|
+
}] })).map((r) => ({
|
|
25
|
+
type: r.type,
|
|
26
|
+
content: r.content
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/** Current state and latest messages of a room the user may see (also for peeking). */
|
|
30
|
+
async function roomInfo(hs, s, roomId, limit) {
|
|
31
|
+
const state = await visibleState(hs, roomId, s.userId);
|
|
32
|
+
const membership = (await hs.db.findOne("memberships", [{
|
|
33
|
+
field: "room_id",
|
|
34
|
+
value: roomId
|
|
35
|
+
}, {
|
|
36
|
+
field: "user_id",
|
|
37
|
+
value: s.userId
|
|
38
|
+
}]))?.membership ?? void 0;
|
|
39
|
+
const position = await hs.streams.position();
|
|
40
|
+
const { events, end } = await paginate(hs, s.userId, roomId, {
|
|
41
|
+
from: {
|
|
42
|
+
depth: null,
|
|
43
|
+
stream: position
|
|
44
|
+
},
|
|
45
|
+
to: null,
|
|
46
|
+
dir: "b",
|
|
47
|
+
limit
|
|
48
|
+
});
|
|
49
|
+
const chunk = events.reverse();
|
|
50
|
+
const room = await hs.db.findOne("rooms", [{
|
|
51
|
+
field: "room_id",
|
|
52
|
+
value: roomId
|
|
53
|
+
}]);
|
|
54
|
+
return {
|
|
55
|
+
room_id: roomId,
|
|
56
|
+
...membership ? { membership } : {},
|
|
57
|
+
messages: {
|
|
58
|
+
chunk: chunk.map((e) => hs.rooms.formatEvent(e, viewerOf(s))),
|
|
59
|
+
start: end ?? (chunk.length ? `t${chunk[0].pdu.depth}_${chunk[0].stream}` : `s${position}`),
|
|
60
|
+
end: `s${position}`
|
|
61
|
+
},
|
|
62
|
+
state: [...state.values()].map((e) => toClientEvent(e)),
|
|
63
|
+
visibility: room?.is_public ? "public" : "private",
|
|
64
|
+
account_data: membership ? await roomAccountData(hs, s.userId, roomId) : [],
|
|
65
|
+
presence: [],
|
|
66
|
+
receipts: []
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Events after `from` in the given rooms, oldest first. */
|
|
70
|
+
async function eventsSince(hs, s, roomIds, from, limit) {
|
|
71
|
+
const all = [];
|
|
72
|
+
for (const roomId of roomIds) {
|
|
73
|
+
const { events } = await paginate(hs, s.userId, roomId, {
|
|
74
|
+
from: {
|
|
75
|
+
depth: null,
|
|
76
|
+
stream: from
|
|
77
|
+
},
|
|
78
|
+
to: null,
|
|
79
|
+
dir: "f",
|
|
80
|
+
limit
|
|
81
|
+
});
|
|
82
|
+
all.push(...events);
|
|
83
|
+
}
|
|
84
|
+
return all.sort((a, b) => a.stream - b.stream).slice(0, limit);
|
|
85
|
+
}
|
|
86
|
+
function registerLegacyRoutes(r) {
|
|
87
|
+
r.client("GET", "/initialSync", procedure({
|
|
88
|
+
auth: "user",
|
|
89
|
+
handler: async (ctx) => {
|
|
90
|
+
const s = ctx.session;
|
|
91
|
+
const hs = ctx.hs;
|
|
92
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "10", 10) || 10, 0), 100);
|
|
93
|
+
const memberships = [
|
|
94
|
+
"join",
|
|
95
|
+
"invite",
|
|
96
|
+
...ctx.query.get("archived") === "true" ? ["leave", "ban"] : []
|
|
97
|
+
];
|
|
98
|
+
const rooms = [];
|
|
99
|
+
for (const membership of memberships) for (const roomId of await hs.rooms.roomsForUser(s.userId, membership)) {
|
|
100
|
+
if (membership === "invite") {
|
|
101
|
+
const invite = await hs.rooms.membershipEvent(roomId, s.userId);
|
|
102
|
+
rooms.push({
|
|
103
|
+
room_id: roomId,
|
|
104
|
+
membership,
|
|
105
|
+
...invite ? { invite: hs.rooms.formatEvent(invite, viewerOf(s)) } : {}
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
rooms.push(await roomInfo(hs, s, roomId, limit));
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
const account = await hs.db.findMany("account_data", { where: [{
|
|
114
|
+
field: "user_id",
|
|
115
|
+
value: s.userId
|
|
116
|
+
}, {
|
|
117
|
+
field: "room_id",
|
|
118
|
+
value: ""
|
|
119
|
+
}] });
|
|
120
|
+
return {
|
|
121
|
+
end: `s${await hs.streams.position()}`,
|
|
122
|
+
rooms,
|
|
123
|
+
presence: [],
|
|
124
|
+
account_data: account.map((a) => ({
|
|
125
|
+
type: a.type,
|
|
126
|
+
content: a.content
|
|
127
|
+
}))
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}));
|
|
131
|
+
r.client("GET", "/rooms/:roomId/initialSync", procedure({
|
|
132
|
+
auth: "user",
|
|
133
|
+
handler: async (ctx) => {
|
|
134
|
+
const s = ctx.session;
|
|
135
|
+
if (!isValidRoomId(ctx.params.roomId)) throw errors.invalidParam("Invalid room ID");
|
|
136
|
+
const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "10", 10) || 10, 0), 100);
|
|
137
|
+
return roomInfo(ctx.hs, s, ctx.params.roomId, limit);
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
r.client("GET", "/events/:eventId", procedure({
|
|
141
|
+
auth: "user",
|
|
142
|
+
handler: async (ctx) => {
|
|
143
|
+
const s = ctx.session;
|
|
144
|
+
const ev = await ctx.hs.rooms.getEvent(ctx.params.eventId);
|
|
145
|
+
if (!ev || !(await ctx.hs.rooms.filterVisible(s.userId, ev.pdu.room_id, [ev])).length) throw errors.notFound("Event not found");
|
|
146
|
+
return ctx.hs.rooms.formatEvent(ev, viewerOf(s), { includeRoomId: true });
|
|
147
|
+
}
|
|
148
|
+
}));
|
|
149
|
+
r.client("GET", "/events", procedure({
|
|
150
|
+
auth: "user",
|
|
151
|
+
handler: async (ctx) => {
|
|
152
|
+
const s = ctx.session;
|
|
153
|
+
const hs = ctx.hs;
|
|
154
|
+
const now = await hs.streams.position();
|
|
155
|
+
const from = parseStreamToken(ctx.query.get("from"), now);
|
|
156
|
+
const timeout = Math.min(Math.max(Number(ctx.query.get("timeout") ?? 3e4) || 0, 0), hs.config.maxSyncTimeoutMs);
|
|
157
|
+
const peekRoom = ctx.query.get("room_id");
|
|
158
|
+
let roomIds;
|
|
159
|
+
if (peekRoom !== null) {
|
|
160
|
+
if (!isValidRoomId(peekRoom)) throw errors.invalidParam("Invalid room ID");
|
|
161
|
+
const { joined, worldReadable } = await hs.rooms.canPeek(peekRoom, s.userId);
|
|
162
|
+
if (!joined && !worldReadable) throw errors.forbidden("You cannot peek into this room");
|
|
163
|
+
roomIds = [peekRoom];
|
|
164
|
+
} else roomIds = await hs.rooms.roomsForUser(s.userId);
|
|
165
|
+
const deadline = Date.now() + timeout;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const events = await eventsSince(hs, s, roomIds, from, 100);
|
|
168
|
+
const remaining = deadline - Date.now();
|
|
169
|
+
if (events.length || remaining <= 0 || ctx.request.signal.aborted) {
|
|
170
|
+
const end = events.length ? events[events.length - 1].stream : Math.max(from, await hs.streams.position());
|
|
171
|
+
return {
|
|
172
|
+
start: `s${from}`,
|
|
173
|
+
end: `s${end}`,
|
|
174
|
+
chunk: events.map((e) => hs.rooms.formatEvent(e, viewerOf(s), { includeRoomId: true }))
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
await Promise.race([hs.notifier.wait(s.userId, remaining, ctx.request.signal), new Promise((resolve) => setTimeout(resolve, Math.min(remaining, peekRoom !== null ? 1e3 : remaining)).unref?.())]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
export { registerLegacyRoutes };
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { randomString } from "../core/crypto.js";
|
|
2
|
+
import { M } from "../core/m.js";
|
|
3
|
+
import { MatrixError, errors } from "../core/errors.js";
|
|
4
|
+
import { procedure } from "../http/procedure.js";
|
|
5
|
+
//#region src/api/media.ts
|
|
6
|
+
const DEFAULT_MAX_UPLOAD = 52428800;
|
|
7
|
+
const INLINE_TYPES = /^(image\/(png|jpeg|gif|webp|avif|apng)|video\/(mp4|webm|ogg)|audio\/(mpeg|ogg|wav|webm|mp4|aac|flac)|text\/plain)$/;
|
|
8
|
+
const maxUpload = (hs) => hs.media.maxUploadSize ?? DEFAULT_MAX_UPLOAD;
|
|
9
|
+
async function storeUpload(ctx, mediaId) {
|
|
10
|
+
const hs = ctx.hs;
|
|
11
|
+
const data = await ctx.bytes();
|
|
12
|
+
if (data.length > maxUpload(hs)) throw errors.tooLarge("Upload too large");
|
|
13
|
+
const contentType = ctx.request.headers.get("content-type");
|
|
14
|
+
const filename = ctx.query.get("filename");
|
|
15
|
+
await hs.media.put(mediaId, data, {
|
|
16
|
+
contentType,
|
|
17
|
+
filename,
|
|
18
|
+
size: data.length
|
|
19
|
+
});
|
|
20
|
+
await hs.db.upsert("media", {
|
|
21
|
+
media_id: mediaId,
|
|
22
|
+
content_type: contentType,
|
|
23
|
+
filename,
|
|
24
|
+
size: data.length,
|
|
25
|
+
uploader: (await ctx.auth()).userId,
|
|
26
|
+
created_ts: Date.now(),
|
|
27
|
+
pending: false
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function contentDisposition(contentType, filename) {
|
|
31
|
+
const type = INLINE_TYPES.test(contentType ?? "") ? "inline" : "attachment";
|
|
32
|
+
return filename ? `${type}; filename*=utf-8''${encodeURIComponent(filename)}` : type;
|
|
33
|
+
}
|
|
34
|
+
/** Fetch (and cache) media from another server. */
|
|
35
|
+
async function remoteMedia(ctx) {
|
|
36
|
+
const hs = ctx.hs;
|
|
37
|
+
const { serverName, mediaId } = ctx.params;
|
|
38
|
+
if (ctx.query.get("allow_remote") === "false") throw errors.notFound("Remote media not allowed");
|
|
39
|
+
const cached = await hs.db.findOne("remote_media", [{
|
|
40
|
+
field: "server_name",
|
|
41
|
+
value: serverName
|
|
42
|
+
}, {
|
|
43
|
+
field: "media_id",
|
|
44
|
+
value: mediaId
|
|
45
|
+
}]);
|
|
46
|
+
if (cached) {
|
|
47
|
+
const data = await hs.media.get(cached.local_id);
|
|
48
|
+
if (data) return {
|
|
49
|
+
data,
|
|
50
|
+
contentType: cached.content_type,
|
|
51
|
+
filename: cached.filename
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
let fetched;
|
|
55
|
+
try {
|
|
56
|
+
fetched = await hs.federation.fetchMedia(serverName, mediaId);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof MatrixError) throw err;
|
|
59
|
+
throw new MatrixError(502, "M_UNKNOWN", "Could not fetch remote media");
|
|
60
|
+
}
|
|
61
|
+
if (fetched.data.length > maxUpload(hs)) throw errors.tooLarge("Remote media too large");
|
|
62
|
+
const localId = `remote_${randomString(24)}`;
|
|
63
|
+
await hs.media.put(localId, fetched.data, {
|
|
64
|
+
contentType: fetched.contentType,
|
|
65
|
+
filename: fetched.filename,
|
|
66
|
+
size: fetched.data.length
|
|
67
|
+
});
|
|
68
|
+
await hs.db.upsert("remote_media", {
|
|
69
|
+
server_name: serverName,
|
|
70
|
+
media_id: mediaId,
|
|
71
|
+
local_id: localId,
|
|
72
|
+
content_type: fetched.contentType,
|
|
73
|
+
filename: fetched.filename,
|
|
74
|
+
size: fetched.data.length,
|
|
75
|
+
fetched_ts: Date.now()
|
|
76
|
+
});
|
|
77
|
+
return fetched;
|
|
78
|
+
}
|
|
79
|
+
async function download(ctx) {
|
|
80
|
+
const hs = ctx.hs;
|
|
81
|
+
const { serverName, mediaId } = ctx.params;
|
|
82
|
+
if (serverName !== hs.serverName) {
|
|
83
|
+
const remote = await remoteMedia(ctx);
|
|
84
|
+
const filename = ctx.params.fileName ?? remote.filename;
|
|
85
|
+
return new Response(remote.data, {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": remote.contentType ?? "application/octet-stream",
|
|
89
|
+
"content-disposition": contentDisposition(remote.contentType, filename),
|
|
90
|
+
"content-security-policy": "sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; style-src 'unsafe-inline'; media-src 'self'; object-src 'self';",
|
|
91
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
92
|
+
"cache-control": "public, max-age=86400, immutable"
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const meta = await hs.db.findOne("media", [{
|
|
97
|
+
field: "media_id",
|
|
98
|
+
value: mediaId
|
|
99
|
+
}]);
|
|
100
|
+
if (!meta) throw errors.notFound("Media not found");
|
|
101
|
+
if (meta.pending) throw new MatrixError(504, "M_NOT_YET_UPLOADED", "Media has not been uploaded yet");
|
|
102
|
+
const data = await hs.media.get(mediaId);
|
|
103
|
+
if (!data) throw errors.notFound("Media not found");
|
|
104
|
+
const filename = ctx.params.fileName ?? meta.filename;
|
|
105
|
+
return new Response(data, {
|
|
106
|
+
status: 200,
|
|
107
|
+
headers: {
|
|
108
|
+
"content-type": meta.content_type ?? "application/octet-stream",
|
|
109
|
+
"content-disposition": contentDisposition(meta.content_type, filename),
|
|
110
|
+
"content-security-policy": "sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; style-src 'unsafe-inline'; media-src 'self'; object-src 'self';",
|
|
111
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
112
|
+
"cache-control": "public, max-age=86400, immutable"
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Serve a thumbnail, generating and caching it with the configured thumbnailer. */
|
|
117
|
+
async function thumbnail(ctx) {
|
|
118
|
+
const hs = ctx.hs;
|
|
119
|
+
const width = Number.parseInt(ctx.query.get("width") ?? "", 10);
|
|
120
|
+
const height = Number.parseInt(ctx.query.get("height") ?? "", 10);
|
|
121
|
+
if (!(width > 0) || !(height > 0)) throw errors.invalidParam("width and height must be positive integers");
|
|
122
|
+
const method = ctx.query.get("method") === "crop" ? "crop" : "scale";
|
|
123
|
+
const animated = ctx.query.get("animated") === "true";
|
|
124
|
+
const { serverName, mediaId } = ctx.params;
|
|
125
|
+
if (serverName !== hs.serverName || !hs.config.thumbnailer) return download(ctx);
|
|
126
|
+
const meta = await hs.db.findOne("media", [{
|
|
127
|
+
field: "media_id",
|
|
128
|
+
value: mediaId
|
|
129
|
+
}]);
|
|
130
|
+
if (!meta || meta.pending || !meta.content_type?.startsWith("image/")) return download(ctx);
|
|
131
|
+
const sizes = [
|
|
132
|
+
32,
|
|
133
|
+
96,
|
|
134
|
+
320,
|
|
135
|
+
640,
|
|
136
|
+
800,
|
|
137
|
+
1280
|
|
138
|
+
];
|
|
139
|
+
const w = sizes.find((s) => s >= width) ?? 1280;
|
|
140
|
+
const h = sizes.find((s) => s >= height) ?? 1280;
|
|
141
|
+
const thumbId = `${mediaId}_thumb_${w}x${h}_${method}${animated ? "_a" : ""}`;
|
|
142
|
+
let thumb = await hs.media.get(thumbId);
|
|
143
|
+
let contentType = (await hs.db.findOne("media", [{
|
|
144
|
+
field: "media_id",
|
|
145
|
+
value: thumbId
|
|
146
|
+
}]))?.content_type ?? null;
|
|
147
|
+
if (!thumb || !contentType) {
|
|
148
|
+
const original = await hs.media.get(mediaId);
|
|
149
|
+
if (!original) throw errors.notFound("Media not found");
|
|
150
|
+
const made = await hs.config.thumbnailer(original, meta.content_type, {
|
|
151
|
+
width: w,
|
|
152
|
+
height: h,
|
|
153
|
+
method,
|
|
154
|
+
animated
|
|
155
|
+
}).catch((err) => {
|
|
156
|
+
hs.logger.warn("Thumbnailing failed", mediaId, err);
|
|
157
|
+
return null;
|
|
158
|
+
});
|
|
159
|
+
if (!made) return download(ctx);
|
|
160
|
+
thumb = made.data;
|
|
161
|
+
contentType = made.contentType;
|
|
162
|
+
await hs.media.put(thumbId, thumb, {
|
|
163
|
+
contentType,
|
|
164
|
+
filename: null,
|
|
165
|
+
size: thumb.length
|
|
166
|
+
});
|
|
167
|
+
await hs.db.upsert("media", {
|
|
168
|
+
media_id: thumbId,
|
|
169
|
+
content_type: contentType,
|
|
170
|
+
filename: null,
|
|
171
|
+
size: thumb.length,
|
|
172
|
+
uploader: "",
|
|
173
|
+
created_ts: Date.now(),
|
|
174
|
+
pending: false
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return new Response(thumb, {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: {
|
|
180
|
+
"content-type": contentType,
|
|
181
|
+
"content-disposition": "inline",
|
|
182
|
+
"cross-origin-resource-policy": "cross-origin",
|
|
183
|
+
"cache-control": "public, max-age=86400, immutable"
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function registerMediaRoutes(r) {
|
|
188
|
+
const config = (ctx) => ({ [M.UPLOAD.SIZE]: maxUpload(ctx.hs) });
|
|
189
|
+
r.get("/_matrix/media/v3/config", procedure({
|
|
190
|
+
auth: "user",
|
|
191
|
+
handler: async (ctx) => {
|
|
192
|
+
return config(ctx);
|
|
193
|
+
}
|
|
194
|
+
}));
|
|
195
|
+
r.get("/_matrix/media/r0/config", procedure({
|
|
196
|
+
auth: "user",
|
|
197
|
+
handler: async (ctx) => {
|
|
198
|
+
return config(ctx);
|
|
199
|
+
}
|
|
200
|
+
}));
|
|
201
|
+
r.get("/_matrix/client/v1/media/config", procedure({
|
|
202
|
+
auth: "user",
|
|
203
|
+
handler: async (ctx) => {
|
|
204
|
+
return config(ctx);
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
const upload = async (ctx) => {
|
|
208
|
+
await ctx.auth();
|
|
209
|
+
await ctx.rateLimit("upload");
|
|
210
|
+
const mediaId = randomString(24);
|
|
211
|
+
await storeUpload(ctx, mediaId);
|
|
212
|
+
return { content_uri: `mxc://${ctx.hs.serverName}/${mediaId}` };
|
|
213
|
+
};
|
|
214
|
+
r.post("/_matrix/media/v3/upload", procedure({ handler: upload }));
|
|
215
|
+
r.post("/_matrix/media/r0/upload", procedure({ handler: upload }));
|
|
216
|
+
r.post("/_matrix/media/v1/create", procedure({
|
|
217
|
+
auth: "user",
|
|
218
|
+
handler: async (ctx) => {
|
|
219
|
+
const s = ctx.session;
|
|
220
|
+
const mediaId = randomString(24);
|
|
221
|
+
await ctx.hs.db.create("media", {
|
|
222
|
+
media_id: mediaId,
|
|
223
|
+
content_type: null,
|
|
224
|
+
filename: null,
|
|
225
|
+
size: 0,
|
|
226
|
+
uploader: s.userId,
|
|
227
|
+
created_ts: Date.now(),
|
|
228
|
+
pending: true
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
content_uri: `mxc://${ctx.hs.serverName}/${mediaId}`,
|
|
232
|
+
unused_expires_at: Date.now() + 864e5
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}));
|
|
236
|
+
r.put("/_matrix/media/v3/upload/:serverName/:mediaId", procedure({
|
|
237
|
+
auth: "user",
|
|
238
|
+
handler: async (ctx) => {
|
|
239
|
+
const s = ctx.session;
|
|
240
|
+
if (ctx.params.serverName !== ctx.hs.serverName) throw errors.notFound("Unknown media");
|
|
241
|
+
const meta = await ctx.hs.db.findOne("media", [{
|
|
242
|
+
field: "media_id",
|
|
243
|
+
value: ctx.params.mediaId
|
|
244
|
+
}]);
|
|
245
|
+
if (!meta) throw errors.notFound("Unknown media");
|
|
246
|
+
if (meta.uploader !== s.userId) throw errors.forbidden("You did not create this media ID");
|
|
247
|
+
if (!meta.pending) throw new MatrixError(409, "M_CANNOT_OVERWRITE_MEDIA", "Media already uploaded");
|
|
248
|
+
await storeUpload(ctx, ctx.params.mediaId);
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
for (const prefix of ["/_matrix/media/v3", "/_matrix/media/r0"]) {
|
|
253
|
+
r.get(`${prefix}/download/:serverName/:mediaId`, procedure({ handler: download }));
|
|
254
|
+
r.get(`${prefix}/download/:serverName/:mediaId/:fileName`, procedure({ handler: download }));
|
|
255
|
+
r.get(`${prefix}/thumbnail/:serverName/:mediaId`, procedure({ handler: thumbnail }));
|
|
256
|
+
}
|
|
257
|
+
const authed = async (ctx) => {
|
|
258
|
+
await ctx.auth();
|
|
259
|
+
return download(ctx);
|
|
260
|
+
};
|
|
261
|
+
r.get("/_matrix/client/v1/media/download/:serverName/:mediaId", procedure({ handler: authed }));
|
|
262
|
+
r.get("/_matrix/client/v1/media/download/:serverName/:mediaId/:fileName", procedure({ handler: authed }));
|
|
263
|
+
r.get("/_matrix/client/v1/media/thumbnail/:serverName/:mediaId", procedure({
|
|
264
|
+
auth: "user",
|
|
265
|
+
handler: async (ctx) => {
|
|
266
|
+
return thumbnail(ctx);
|
|
267
|
+
}
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
export { registerMediaRoutes };
|
package/dist/api/misc.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { signJson } from "../core/crypto.js";
|
|
2
|
+
import { M, UNSTABLE } from "../core/m.js";
|
|
3
|
+
import { SUPPORTED_ROOM_VERSIONS } from "../core/events.js";
|
|
4
|
+
import { errors } from "../core/errors.js";
|
|
5
|
+
import { procedure } from "../http/procedure.js";
|
|
6
|
+
import { wellKnown } from "./account.js";
|
|
7
|
+
import { canonicalJsonResponse } from "../http/handler.js";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
//#region src/api/misc.ts
|
|
10
|
+
const PACKAGE = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
11
|
+
const SPEC_VERSIONS = [
|
|
12
|
+
"r0.0.1",
|
|
13
|
+
"r0.1.0",
|
|
14
|
+
"r0.2.0",
|
|
15
|
+
"r0.3.0",
|
|
16
|
+
"r0.4.0",
|
|
17
|
+
"r0.5.0",
|
|
18
|
+
"r0.6.0",
|
|
19
|
+
"r0.6.1",
|
|
20
|
+
"v1.1",
|
|
21
|
+
"v1.2",
|
|
22
|
+
"v1.3",
|
|
23
|
+
"v1.4",
|
|
24
|
+
"v1.5",
|
|
25
|
+
"v1.6",
|
|
26
|
+
"v1.7",
|
|
27
|
+
"v1.8",
|
|
28
|
+
"v1.9",
|
|
29
|
+
"v1.10",
|
|
30
|
+
"v1.11"
|
|
31
|
+
];
|
|
32
|
+
function registerMiscRoutes(r) {
|
|
33
|
+
r.get("/_matrix/client/versions", procedure({ handler: () => ({
|
|
34
|
+
versions: SPEC_VERSIONS,
|
|
35
|
+
unstable_features: {
|
|
36
|
+
[UNSTABLE.ORG.MATRIX.SIMPLIFIED_MSC3575]: true,
|
|
37
|
+
[UNSTABLE.ORG.MATRIX.MSC3916.STABLE]: true,
|
|
38
|
+
[UNSTABLE.ORG.MATRIX.MSC4140._]: true,
|
|
39
|
+
[UNSTABLE.ORG.MATRIX.MSC4143._]: true,
|
|
40
|
+
[UNSTABLE.ORG.MATRIX.MSC4222._]: true,
|
|
41
|
+
[UNSTABLE.ORG.MATRIX.MSC4306]: true
|
|
42
|
+
}
|
|
43
|
+
}) }));
|
|
44
|
+
r.get("/.well-known/matrix/client", procedure({ handler: (ctx) => {
|
|
45
|
+
const wk = wellKnown(ctx.hs);
|
|
46
|
+
if (!wk) throw errors.notFound("No well-known configured");
|
|
47
|
+
return wk;
|
|
48
|
+
} }));
|
|
49
|
+
r.get("/.well-known/matrix/server", procedure({ handler: (ctx) => {
|
|
50
|
+
if (!ctx.hs.config.federationAddress) throw errors.notFound("No delegation configured");
|
|
51
|
+
return { [M.SERVER]: ctx.hs.config.federationAddress };
|
|
52
|
+
} }));
|
|
53
|
+
r.get("/.well-known/matrix/support", procedure({ handler: (ctx) => {
|
|
54
|
+
if (!ctx.hs.config.support) throw errors.notFound("No support information configured");
|
|
55
|
+
return ctx.hs.config.support;
|
|
56
|
+
} }));
|
|
57
|
+
r.client("GET", "/capabilities", procedure({
|
|
58
|
+
auth: "user",
|
|
59
|
+
handler: async (ctx) => {
|
|
60
|
+
const available = {};
|
|
61
|
+
for (const v of SUPPORTED_ROOM_VERSIONS) available[v] = /^\d+$/.test(v) ? "stable" : "unstable";
|
|
62
|
+
return { capabilities: {
|
|
63
|
+
[M.CHANGE_PASSWORD]: { enabled: !ctx.hs.oauth && ctx.hs.userAuth.passwordLoginEnabled },
|
|
64
|
+
[M.ROOM_VERSIONS]: {
|
|
65
|
+
default: ctx.hs.config.defaultRoomVersion,
|
|
66
|
+
available
|
|
67
|
+
},
|
|
68
|
+
[M.SET_DISPLAYNAME]: { enabled: true },
|
|
69
|
+
[M.SET_AVATAR_URL]: { enabled: true },
|
|
70
|
+
[M.PROFILE_FIELDS]: { enabled: true },
|
|
71
|
+
[M.THREEPID_CHANGES]: { enabled: ctx.hs.threepids.enabled && !ctx.hs.oauth },
|
|
72
|
+
[M.GET_LOGIN_TOKEN]: { enabled: ctx.hs.config.loginViaExistingSession && !ctx.hs.oauth }
|
|
73
|
+
} };
|
|
74
|
+
}
|
|
75
|
+
}));
|
|
76
|
+
r.get("/_matrix/key/v2/server", procedure({ handler: (ctx) => {
|
|
77
|
+
const hs = ctx.hs;
|
|
78
|
+
const key = hs.signingKey;
|
|
79
|
+
return canonicalJsonResponse(signJson({
|
|
80
|
+
server_name: hs.serverName,
|
|
81
|
+
valid_until_ts: Date.now() + 864e5,
|
|
82
|
+
verify_keys: { [`ed25519:${key.keyId}`]: { key: key.publicKeyBase64 } },
|
|
83
|
+
old_verify_keys: {}
|
|
84
|
+
}, hs.serverName, key));
|
|
85
|
+
} }));
|
|
86
|
+
r.get("/_matrix/federation/v1/version", procedure({ handler: (ctx) => {
|
|
87
|
+
if (!ctx.hs.federation.enabled) throw errors.unrecognized("Federation is disabled");
|
|
88
|
+
return { server: {
|
|
89
|
+
name: PACKAGE.name,
|
|
90
|
+
version: PACKAGE.version
|
|
91
|
+
} };
|
|
92
|
+
} }));
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { SPEC_VERSIONS, registerMiscRoutes };
|