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.
Files changed (141) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +479 -2
  3. package/dist/adapters/auth.d.ts +50 -0
  4. package/dist/adapters/auth.js +18 -0
  5. package/dist/adapters/cluster.d.ts +55 -0
  6. package/dist/adapters/cluster.js +249 -0
  7. package/dist/adapters/media.d.ts +50 -0
  8. package/dist/adapters/media.js +63 -0
  9. package/dist/adapters/s3.d.ts +33 -0
  10. package/dist/adapters/s3.js +95 -0
  11. package/dist/api/account.js +594 -0
  12. package/dist/api/admin.js +267 -0
  13. package/dist/api/appservice.js +45 -0
  14. package/dist/api/backup.js +280 -0
  15. package/dist/api/federation.js +826 -0
  16. package/dist/api/filters.js +88 -0
  17. package/dist/api/index.js +47 -0
  18. package/dist/api/legacy.js +183 -0
  19. package/dist/api/media.js +271 -0
  20. package/dist/api/misc.js +95 -0
  21. package/dist/api/preview.js +201 -0
  22. package/dist/api/profile.js +176 -0
  23. package/dist/api/push.js +202 -0
  24. package/dist/api/relationships.js +270 -0
  25. package/dist/api/rooms.js +1487 -0
  26. package/dist/api/rtc.d.ts +23 -0
  27. package/dist/api/rtc.js +66 -0
  28. package/dist/api/search.js +161 -0
  29. package/dist/api/slidingsync.js +474 -0
  30. package/dist/api/spaces.js +177 -0
  31. package/dist/api/sync.js +609 -0
  32. package/dist/api/threadsubs.js +140 -0
  33. package/dist/api/threepid.js +155 -0
  34. package/dist/api/uia.js +127 -0
  35. package/dist/api/user.js +329 -0
  36. package/dist/core/admin.d.ts +84 -0
  37. package/dist/core/admin.js +308 -0
  38. package/dist/core/adminroom.d.ts +49 -0
  39. package/dist/core/adminroom.js +483 -0
  40. package/dist/core/appservices.d.ts +74 -0
  41. package/dist/core/appservices.js +369 -0
  42. package/dist/core/audit.d.ts +50 -0
  43. package/dist/core/audit.js +86 -0
  44. package/dist/core/auth.d.ts +48 -0
  45. package/dist/core/auth.js +237 -0
  46. package/dist/core/authrules.d.ts +4 -0
  47. package/dist/core/authrules.js +348 -0
  48. package/dist/core/cluster.d.ts +66 -0
  49. package/dist/core/cluster.js +191 -0
  50. package/dist/core/crypto.d.ts +10 -0
  51. package/dist/core/crypto.js +100 -0
  52. package/dist/core/delayed.d.ts +39 -0
  53. package/dist/core/delayed.js +160 -0
  54. package/dist/core/errors.d.ts +9 -0
  55. package/dist/core/errors.js +45 -0
  56. package/dist/core/events.d.ts +32 -0
  57. package/dist/core/events.js +186 -0
  58. package/dist/core/glob.js +66 -0
  59. package/dist/core/identity.d.ts +24 -0
  60. package/dist/core/identity.js +96 -0
  61. package/dist/core/ids.js +36 -0
  62. package/dist/core/invitefilter.js +36 -0
  63. package/dist/core/json.js +16 -0
  64. package/dist/core/keys.d.ts +36 -0
  65. package/dist/core/keys.js +450 -0
  66. package/dist/core/m.d.ts +209 -0
  67. package/dist/core/m.js +182 -0
  68. package/dist/core/notifier.d.ts +17 -0
  69. package/dist/core/notifier.js +54 -0
  70. package/dist/core/oauth.d.ts +38 -0
  71. package/dist/core/oauth.js +108 -0
  72. package/dist/core/partialstate.d.ts +32 -0
  73. package/dist/core/partialstate.js +198 -0
  74. package/dist/core/phone.js +268 -0
  75. package/dist/core/policy.d.ts +29 -0
  76. package/dist/core/policy.js +97 -0
  77. package/dist/core/profile.js +39 -0
  78. package/dist/core/push.d.ts +46 -0
  79. package/dist/core/push.js +307 -0
  80. package/dist/core/pushrules.js +229 -0
  81. package/dist/core/ratelimit.d.ts +23 -0
  82. package/dist/core/ratelimit.js +77 -0
  83. package/dist/core/rooms.d.ts +215 -0
  84. package/dist/core/rooms.js +1731 -0
  85. package/dist/core/settings.d.ts +11 -0
  86. package/dist/core/settings.js +29 -0
  87. package/dist/core/sso.d.ts +46 -0
  88. package/dist/core/sso.js +165 -0
  89. package/dist/core/stateres.d.ts +4 -0
  90. package/dist/core/stateres.js +209 -0
  91. package/dist/core/threepid.d.ts +80 -0
  92. package/dist/core/threepid.js +275 -0
  93. package/dist/core/typing.d.ts +16 -0
  94. package/dist/core/typing.js +60 -0
  95. package/dist/federation/client.d.ts +51 -0
  96. package/dist/federation/client.js +279 -0
  97. package/dist/federation/index.d.ts +153 -0
  98. package/dist/federation/index.js +1059 -0
  99. package/dist/federation/keyring.d.ts +26 -0
  100. package/dist/federation/keyring.js +122 -0
  101. package/dist/federation/sender.d.ts +26 -0
  102. package/dist/federation/sender.js +164 -0
  103. package/dist/homeserver.d.ts +224 -0
  104. package/dist/homeserver.js +302 -0
  105. package/dist/http/adapters.d.ts +40 -0
  106. package/dist/http/adapters.js +130 -0
  107. package/dist/http/handler.d.ts +8 -0
  108. package/dist/http/handler.js +181 -0
  109. package/dist/http/input.js +24 -0
  110. package/dist/http/procedure.js +25 -0
  111. package/dist/http/router.d.ts +43 -0
  112. package/dist/http/router.js +56 -0
  113. package/dist/index.d.ts +26 -0
  114. package/dist/index.js +15 -0
  115. package/dist/storage/memory.d.ts +5 -0
  116. package/dist/storage/memory.js +122 -0
  117. package/dist/storage/schema.d.ts +4 -0
  118. package/dist/storage/schema.js +664 -0
  119. package/dist/storage/sql.d.ts +72 -0
  120. package/dist/storage/sql.js +274 -0
  121. package/dist/storage/types.d.ts +55 -0
  122. package/dist/vendor/zod/LICENSE +21 -0
  123. package/dist/vendor/zod/v4/classic/errors.js +54 -0
  124. package/dist/vendor/zod/v4/classic/parse.js +17 -0
  125. package/dist/vendor/zod/v4/classic/schemas.js +865 -0
  126. package/dist/vendor/zod/v4/core/api.js +512 -0
  127. package/dist/vendor/zod/v4/core/checks.js +300 -0
  128. package/dist/vendor/zod/v4/core/core.js +109 -0
  129. package/dist/vendor/zod/v4/core/doc.js +35 -0
  130. package/dist/vendor/zod/v4/core/errors.js +118 -0
  131. package/dist/vendor/zod/v4/core/json-schema-processors.js +428 -0
  132. package/dist/vendor/zod/v4/core/memoizer.js +255 -0
  133. package/dist/vendor/zod/v4/core/parse.js +206 -0
  134. package/dist/vendor/zod/v4/core/regexes.js +73 -0
  135. package/dist/vendor/zod/v4/core/registries.js +48 -0
  136. package/dist/vendor/zod/v4/core/schemas.js +1378 -0
  137. package/dist/vendor/zod/v4/core/to-json-schema.js +448 -0
  138. package/dist/vendor/zod/v4/core/util.js +537 -0
  139. package/dist/vendor/zod/v4/core/versions.js +8 -0
  140. package/dist/vendor/zod/v4/locales/en.js +110 -0
  141. 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 };
@@ -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 };