matrix-nodejs-homeserver 0.0.0-stage → 0.2.0

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 (120) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +457 -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 +560 -0
  12. package/dist/api/admin.js +251 -0
  13. package/dist/api/appservice.js +33 -0
  14. package/dist/api/backup.js +269 -0
  15. package/dist/api/federation.js +792 -0
  16. package/dist/api/filters.js +87 -0
  17. package/dist/api/index.js +47 -0
  18. package/dist/api/legacy.js +170 -0
  19. package/dist/api/media.js +255 -0
  20. package/dist/api/misc.js +86 -0
  21. package/dist/api/preview.js +200 -0
  22. package/dist/api/profile.js +165 -0
  23. package/dist/api/push.js +187 -0
  24. package/dist/api/relationships.js +264 -0
  25. package/dist/api/rooms.js +1364 -0
  26. package/dist/api/rtc.d.ts +24 -0
  27. package/dist/api/rtc.js +52 -0
  28. package/dist/api/search.js +155 -0
  29. package/dist/api/slidingsync.js +464 -0
  30. package/dist/api/spaces.js +172 -0
  31. package/dist/api/sync.js +600 -0
  32. package/dist/api/threadsubs.js +129 -0
  33. package/dist/api/threepid.js +122 -0
  34. package/dist/api/uia.js +126 -0
  35. package/dist/api/user.js +286 -0
  36. package/dist/core/admin.d.ts +85 -0
  37. package/dist/core/admin.js +307 -0
  38. package/dist/core/adminroom.d.ts +49 -0
  39. package/dist/core/adminroom.js +482 -0
  40. package/dist/core/appservices.d.ts +73 -0
  41. package/dist/core/appservices.js +368 -0
  42. package/dist/core/audit.d.ts +51 -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 +347 -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 +159 -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 +185 -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 +35 -0
  63. package/dist/core/json.js +16 -0
  64. package/dist/core/keys.d.ts +36 -0
  65. package/dist/core/keys.js +449 -0
  66. package/dist/core/notifier.d.ts +17 -0
  67. package/dist/core/notifier.js +54 -0
  68. package/dist/core/oauth.d.ts +38 -0
  69. package/dist/core/oauth.js +108 -0
  70. package/dist/core/partialstate.d.ts +32 -0
  71. package/dist/core/partialstate.js +197 -0
  72. package/dist/core/phone.js +268 -0
  73. package/dist/core/policy.d.ts +29 -0
  74. package/dist/core/policy.js +96 -0
  75. package/dist/core/profile.js +39 -0
  76. package/dist/core/push.d.ts +47 -0
  77. package/dist/core/push.js +306 -0
  78. package/dist/core/pushrules.d.ts +1 -0
  79. package/dist/core/pushrules.js +228 -0
  80. package/dist/core/ratelimit.d.ts +23 -0
  81. package/dist/core/ratelimit.js +77 -0
  82. package/dist/core/rooms.d.ts +215 -0
  83. package/dist/core/rooms.js +1729 -0
  84. package/dist/core/settings.d.ts +11 -0
  85. package/dist/core/settings.js +29 -0
  86. package/dist/core/sso.d.ts +46 -0
  87. package/dist/core/sso.js +165 -0
  88. package/dist/core/stateres.d.ts +4 -0
  89. package/dist/core/stateres.js +208 -0
  90. package/dist/core/threepid.d.ts +80 -0
  91. package/dist/core/threepid.js +275 -0
  92. package/dist/core/typing.d.ts +16 -0
  93. package/dist/core/typing.js +60 -0
  94. package/dist/federation/client.d.ts +51 -0
  95. package/dist/federation/client.js +278 -0
  96. package/dist/federation/index.d.ts +153 -0
  97. package/dist/federation/index.js +1058 -0
  98. package/dist/federation/keyring.d.ts +26 -0
  99. package/dist/federation/keyring.js +122 -0
  100. package/dist/federation/sender.d.ts +26 -0
  101. package/dist/federation/sender.js +164 -0
  102. package/dist/homeserver.d.ts +223 -0
  103. package/dist/homeserver.js +301 -0
  104. package/dist/http/adapters.d.ts +40 -0
  105. package/dist/http/adapters.js +130 -0
  106. package/dist/http/handler.d.ts +8 -0
  107. package/dist/http/handler.js +202 -0
  108. package/dist/http/input.js +24 -0
  109. package/dist/http/router.d.ts +60 -0
  110. package/dist/http/router.js +76 -0
  111. package/dist/index.d.ts +25 -0
  112. package/dist/index.js +14 -0
  113. package/dist/storage/memory.d.ts +5 -0
  114. package/dist/storage/memory.js +122 -0
  115. package/dist/storage/schema.d.ts +4 -0
  116. package/dist/storage/schema.js +664 -0
  117. package/dist/storage/sql.d.ts +72 -0
  118. package/dist/storage/sql.js +274 -0
  119. package/dist/storage/types.d.ts +55 -0
  120. package/package.json +67 -4
@@ -0,0 +1,200 @@
1
+ import { randomString } from "../core/crypto.js";
2
+ import { errors } from "../core/errors.js";
3
+ import { lookup } from "node:dns/promises";
4
+ import { BlockList, isIP } from "node:net";
5
+ //#region src/api/preview.ts
6
+ const MAX_BYTES = 1048576;
7
+ const privateRanges = new BlockList();
8
+ for (const [net, prefix] of [
9
+ ["0.0.0.0", 8],
10
+ ["10.0.0.0", 8],
11
+ ["100.64.0.0", 10],
12
+ ["127.0.0.0", 8],
13
+ ["169.254.0.0", 16],
14
+ ["172.16.0.0", 12],
15
+ ["192.0.0.0", 24],
16
+ ["192.168.0.0", 16],
17
+ ["198.18.0.0", 15],
18
+ ["224.0.0.0", 4],
19
+ ["240.0.0.0", 4]
20
+ ]) privateRanges.addSubnet(net, prefix, "ipv4");
21
+ for (const [net, prefix] of [
22
+ ["::", 128],
23
+ ["::1", 128],
24
+ ["fc00::", 7],
25
+ ["fe80::", 10],
26
+ ["ff00::", 8],
27
+ ["::ffff:0:0", 96]
28
+ ]) privateRanges.addSubnet(net, prefix, "ipv6");
29
+ async function assertPublicHost(hostname) {
30
+ const host = hostname.replace(/^\[|\]$/g, "");
31
+ const addresses = isIP(host) ? [{
32
+ address: host,
33
+ family: isIP(host)
34
+ }] : await lookup(host, { all: true });
35
+ for (const { address, family } of addresses) if (privateRanges.check(address, family === 6 ? "ipv6" : "ipv4")) throw errors.forbidden("URL resolves to a private address");
36
+ }
37
+ function decodeEntities(s) {
38
+ return s.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (m) => ({
39
+ "&": "&",
40
+ "&lt;": "<",
41
+ "&gt;": ">",
42
+ "&quot;": "\"",
43
+ "&#39;": "'",
44
+ "&#x27;": "'"
45
+ })[m] ?? m);
46
+ }
47
+ function parseOpenGraph(html) {
48
+ const og = {};
49
+ for (const tag of html.match(/<meta\s[^>]*>/gi) ?? []) {
50
+ const attr = (name) => new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
51
+ const key = attr("property") ?? attr("name");
52
+ const content = attr("content");
53
+ if (!key || !content) continue;
54
+ const k = (key[1] ?? key[2]).toLowerCase();
55
+ if (k.startsWith("og:") && !(k in og)) og[k] = decodeEntities(content[1] ?? content[2]);
56
+ if (k === "description" && !og["og:description"]) og["og:description"] = decodeEntities(content[1] ?? content[2]);
57
+ }
58
+ if (!og["og:title"]) {
59
+ const title = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
60
+ if (title) og["og:title"] = decodeEntities(title[1].trim());
61
+ }
62
+ return og;
63
+ }
64
+ async function preview(hs, rawUrl) {
65
+ const opts = hs.config.urlPreviews;
66
+ if (!opts) throw errors.notFound("URL previews are disabled");
67
+ let url;
68
+ try {
69
+ url = new URL(rawUrl);
70
+ } catch {
71
+ throw errors.invalidParam("Invalid URL");
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw errors.invalidParam("Only http(s) URLs can be previewed");
74
+ for (let redirects = 0; redirects < 5; redirects++) {
75
+ if (!opts.allowPrivateAddresses) await assertPublicHost(url.hostname);
76
+ const res = await fetch(url, {
77
+ redirect: "manual",
78
+ signal: AbortSignal.timeout(1e4),
79
+ headers: { "user-agent": "matrix-nodejs-homeserver" }
80
+ });
81
+ if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
82
+ url = new URL(res.headers.get("location"), url);
83
+ continue;
84
+ }
85
+ if (!res.ok || !res.body) throw errors.notFound("Could not fetch URL");
86
+ const type = res.headers.get("content-type") ?? "";
87
+ if (type.startsWith("image/")) return {
88
+ ...await storeImage(hs, res, type),
89
+ "og:url": url.href
90
+ };
91
+ if (!type.includes("html")) return {};
92
+ let size = 0;
93
+ const chunks = [];
94
+ for await (const chunk of res.body) {
95
+ size += chunk.length;
96
+ if (size > MAX_BYTES) break;
97
+ chunks.push(chunk);
98
+ }
99
+ const og = parseOpenGraph(Buffer.concat(chunks).toString("utf8"));
100
+ if (og["og:image"]) try {
101
+ const imageUrl = new URL(og["og:image"], url);
102
+ if (!opts.allowPrivateAddresses) await assertPublicHost(imageUrl.hostname);
103
+ const img = await fetch(imageUrl, { signal: AbortSignal.timeout(1e4) });
104
+ const imgType = img.headers.get("content-type") ?? "";
105
+ if (img.ok && imgType.startsWith("image/")) Object.assign(og, await storeImage(hs, img, imgType));
106
+ } catch {}
107
+ return og;
108
+ }
109
+ throw errors.notFound("Too many redirects");
110
+ }
111
+ /** Read width/height from PNG, GIF, JPEG or WebP headers. */
112
+ function imageSize(data) {
113
+ const b = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
114
+ if (b.length >= 24 && b.readUInt32BE(0) === 2303741511) return {
115
+ width: b.readUInt32BE(16),
116
+ height: b.readUInt32BE(20)
117
+ };
118
+ if (b.length >= 10 && b.toString("ascii", 0, 3) === "GIF") return {
119
+ width: b.readUInt16LE(6),
120
+ height: b.readUInt16LE(8)
121
+ };
122
+ if (b.length >= 30 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP") {
123
+ const chunk = b.toString("ascii", 12, 16);
124
+ if (chunk === "VP8 ") return {
125
+ width: b.readUInt16LE(26) & 16383,
126
+ height: b.readUInt16LE(28) & 16383
127
+ };
128
+ if (chunk === "VP8L") {
129
+ const bits = b.readUInt32LE(21);
130
+ return {
131
+ width: (bits & 16383) + 1,
132
+ height: (bits >> 14 & 16383) + 1
133
+ };
134
+ }
135
+ if (chunk === "VP8X") return {
136
+ width: b.readUIntLE(24, 3) + 1,
137
+ height: b.readUIntLE(27, 3) + 1
138
+ };
139
+ }
140
+ if (b.length >= 4 && b[0] === 255 && b[1] === 216) {
141
+ let i = 2;
142
+ while (i + 9 < b.length) {
143
+ if (b[i] !== 255) {
144
+ i++;
145
+ continue;
146
+ }
147
+ const marker = b[i + 1];
148
+ const len = b.readUInt16BE(i + 2);
149
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
150
+ height: b.readUInt16BE(i + 5),
151
+ width: b.readUInt16BE(i + 7)
152
+ };
153
+ i += 2 + len;
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+ /** Store a fetched image in the media repository and describe it for a preview. */
159
+ async function storeImage(hs, res, type) {
160
+ const data = new Uint8Array(await res.arrayBuffer());
161
+ if (data.length > MAX_BYTES * 10) return {};
162
+ const mediaId = randomString(24);
163
+ await hs.media.put(mediaId, data, {
164
+ contentType: type,
165
+ filename: null,
166
+ size: data.length
167
+ });
168
+ await hs.db.create("media", {
169
+ media_id: mediaId,
170
+ content_type: type,
171
+ filename: null,
172
+ size: data.length,
173
+ uploader: "",
174
+ created_ts: Date.now(),
175
+ pending: false
176
+ });
177
+ const size = imageSize(data);
178
+ return {
179
+ "og:image": `mxc://${hs.serverName}/${mediaId}`,
180
+ "og:image:type": type,
181
+ "matrix:image:size": data.length,
182
+ ...size ? {
183
+ "og:image:width": size.width,
184
+ "og:image:height": size.height
185
+ } : {}
186
+ };
187
+ }
188
+ function registerPreviewRoutes(r) {
189
+ const handler = async (ctx) => {
190
+ await ctx.auth();
191
+ const url = ctx.query.get("url");
192
+ if (!url) throw errors.missingParam("Missing url");
193
+ return preview(ctx.hs, url);
194
+ };
195
+ r.get("/_matrix/media/v3/preview_url", handler);
196
+ r.get("/_matrix/media/r0/preview_url", handler);
197
+ r.get("/_matrix/client/v1/media/preview_url", handler);
198
+ }
199
+ //#endregion
200
+ export { imageSize, parseOpenGraph, registerPreviewRoutes };
@@ -0,0 +1,165 @@
1
+ import { isObject } from "../core/json.js";
2
+ import { MatrixError, errors } from "../core/errors.js";
3
+ import { isValidUserId } from "../core/ids.js";
4
+ import { getState } from "../core/authrules.js";
5
+ import { assertNotSuspended } from "../core/auth.js";
6
+ import { body, fail, lenient } from "../http/input.js";
7
+ import { BUILTIN_FIELDS, fullProfile, profileSize, setProfileField } from "../core/profile.js";
8
+ import { z } from "zod";
9
+ //#region src/api/profile.ts
10
+ async function remoteProfile(ctx, field) {
11
+ if (!isValidUserId(ctx.params.userId)) throw errors.invalidParam("Invalid user ID");
12
+ try {
13
+ const res = await ctx.hs.federation.queryProfile(ctx.params.userId, field);
14
+ return isObject(res) ? res : {};
15
+ } catch (err) {
16
+ if (err instanceof MatrixError && err.status === 404) throw errors.notFound("Profile not found");
17
+ throw err;
18
+ }
19
+ }
20
+ async function localProfile(ctx) {
21
+ const profile = await fullProfile(ctx.hs, ctx.params.userId);
22
+ if (!profile) throw errors.notFound("Profile not found");
23
+ return profile;
24
+ }
25
+ function checkField(field) {
26
+ if (!field || Buffer.byteLength(field) > 255) throw new MatrixError(400, "M_KEY_TOO_LARGE", "Profile key too long");
27
+ }
28
+ function registerProfileRoutes(r) {
29
+ r.client("GET", "/profile/:userId", async (ctx) => {
30
+ if (!ctx.hs.isLocalUser(ctx.params.userId)) return remoteProfile(ctx);
31
+ return localProfile(ctx);
32
+ });
33
+ r.client("GET", "/profile/:userId/:field", async (ctx) => {
34
+ const field = ctx.params.field;
35
+ checkField(field);
36
+ const profile = ctx.hs.isLocalUser(ctx.params.userId) ? await localProfile(ctx) : await remoteProfile(ctx, field);
37
+ if (profile[field] === void 0 || profile[field] === null) throw errors.notFound("Profile field not set");
38
+ return { [field]: profile[field] };
39
+ });
40
+ async function owner(ctx) {
41
+ const s = await ctx.auth();
42
+ if (!isValidUserId(ctx.params.userId) || ctx.params.userId !== s.userId) throw errors.forbidden("Cannot change another user's profile");
43
+ checkField(ctx.params.field);
44
+ await assertNotSuspended(ctx.hs, s);
45
+ return s;
46
+ }
47
+ r.client("PUT", "/profile/:userId/:field", async (ctx) => {
48
+ const s = await owner(ctx);
49
+ const field = ctx.params.field;
50
+ const body = await ctx.json();
51
+ if (!isObject(body) || !(field in body)) throw errors.badJson(`Body must contain ${field}`);
52
+ if (Object.keys(body).length !== 1) throw errors.badJson("Body must contain only the profile field");
53
+ const value = body[field];
54
+ if (BUILTIN_FIELDS.has(field)) {
55
+ if (value !== null && typeof value !== "string") throw errors.badJson(`${field} must be a string`);
56
+ if (typeof value === "string" && value.length > 1024) throw errors.tooLarge(`${field} too long`);
57
+ }
58
+ const profile = await fullProfile(ctx.hs, s.userId) ?? {};
59
+ profile[field] = value;
60
+ if (profileSize(profile) > 65536) throw new MatrixError(400, "M_PROFILE_TOO_LARGE", "Profile too large");
61
+ await setProfileField(ctx.hs, s.userId, field, value);
62
+ return {};
63
+ });
64
+ r.client("DELETE", "/profile/:userId/:field", async (ctx) => {
65
+ const s = await owner(ctx);
66
+ await setProfileField(ctx.hs, s.userId, ctx.params.field, null);
67
+ return {};
68
+ });
69
+ const directoryBody = body({
70
+ search_term: z.string(fail("M_MISSING_PARAM", "Missing search_term")),
71
+ limit: lenient(z.number().int())
72
+ });
73
+ r.client("POST", "/user_directory/search", { input: directoryBody }, async (ctx) => {
74
+ const s = await ctx.auth();
75
+ const body = ctx.input;
76
+ const term = body.search_term.toLowerCase();
77
+ const limit = body.limit !== void 0 ? Math.min(body.limit, 50) : 10;
78
+ const visible = await ctx.hs.rooms.usersSharingRooms(s.userId);
79
+ visible.delete(s.userId);
80
+ for (const room of await ctx.hs.db.findMany("rooms", {})) {
81
+ const state = await ctx.hs.rooms.currentState(room.room_id);
82
+ if (room.is_public || getState(state, "m.room.join_rules")?.content?.join_rule === "public" || getState(state, "m.room.history_visibility")?.content?.history_visibility === "world_readable") for (const u of await ctx.hs.rooms.joinedMembers(room.room_id)) visible.add(u);
83
+ }
84
+ const candidates = (await ctx.hs.db.findMany("users", { where: [{
85
+ field: "user_id",
86
+ operator: "in",
87
+ value: [...visible].filter((u) => ctx.hs.isLocalUser(u))
88
+ }] })).filter((u) => !u.deactivated).map((u) => ({
89
+ user_id: u.user_id,
90
+ displayname: u.displayname,
91
+ avatar_url: u.avatar_url
92
+ }));
93
+ for (const userId of visible) {
94
+ if (ctx.hs.isLocalUser(userId)) continue;
95
+ const row = (await ctx.hs.db.findMany("memberships", {
96
+ where: [{
97
+ field: "user_id",
98
+ value: userId
99
+ }, {
100
+ field: "membership",
101
+ value: "join"
102
+ }],
103
+ limit: 1
104
+ }))[0];
105
+ const member = row ? await ctx.hs.rooms.getPdu(row.event_id) : null;
106
+ candidates.push({
107
+ user_id: userId,
108
+ displayname: member?.content?.displayname,
109
+ avatar_url: member?.content?.avatar_url
110
+ });
111
+ }
112
+ const results = candidates.filter((u) => u.user_id.toLowerCase().includes(term) || typeof u.displayname === "string" && u.displayname.toLowerCase().includes(term)).map((u) => ({
113
+ user_id: u.user_id,
114
+ display_name: u.displayname ?? void 0,
115
+ avatar_url: u.avatar_url ?? void 0
116
+ }));
117
+ return {
118
+ results: results.slice(0, limit),
119
+ limited: results.length > limit
120
+ };
121
+ });
122
+ r.client("GET", "/presence/:userId/status", async (ctx) => {
123
+ await ctx.auth();
124
+ const row = await ctx.hs.db.findOne("presence", [{
125
+ field: "user_id",
126
+ value: ctx.params.userId
127
+ }]);
128
+ if (!row) {
129
+ if (!await ctx.hs.db.findOne("users", [{
130
+ field: "user_id",
131
+ value: ctx.params.userId
132
+ }])) throw errors.notFound("Unknown user");
133
+ return { presence: "offline" };
134
+ }
135
+ const out = {
136
+ presence: row.presence,
137
+ last_active_ago: Date.now() - row.last_active_ts
138
+ };
139
+ if (row.status_msg) out.status_msg = row.status_msg;
140
+ if (row.presence === "online") out.currently_active = true;
141
+ return out;
142
+ });
143
+ r.client("PUT", "/presence/:userId/status", async (ctx) => {
144
+ const s = await ctx.auth();
145
+ if (ctx.params.userId !== s.userId) throw errors.forbidden("Cannot set another user's presence");
146
+ const body = await ctx.json();
147
+ if (![
148
+ "online",
149
+ "offline",
150
+ "unavailable"
151
+ ].includes(body.presence)) throw errors.invalidParam("Invalid presence");
152
+ await ctx.hs.streams.write((stream) => ctx.hs.db.upsert("presence", {
153
+ user_id: s.userId,
154
+ presence: body.presence,
155
+ status_msg: typeof body.status_msg === "string" ? body.status_msg : null,
156
+ last_active_ts: Date.now(),
157
+ stream
158
+ }));
159
+ await ctx.hs.federation.onPresence(s.userId, body.presence, typeof body.status_msg === "string" ? body.status_msg : null).catch(() => {});
160
+ ctx.hs.notifier.notify(await ctx.hs.rooms.usersSharingRooms(s.userId));
161
+ return {};
162
+ });
163
+ }
164
+ //#endregion
165
+ export { registerProfileRoutes };
@@ -0,0 +1,187 @@
1
+ import { errors } from "../core/errors.js";
2
+ import { KINDS, loadRules, saveRules, withRulesLock } from "../core/pushrules.js";
3
+ import { body, fail, lenient } from "../http/input.js";
4
+ import { z } from "zod";
5
+ //#region src/api/push.ts
6
+ const pusherKey = z.string(fail("M_MISSING_PARAM", "Missing app_id or pushkey"));
7
+ const pusherBody = body({
8
+ app_id: pusherKey,
9
+ pushkey: pusherKey,
10
+ kind: lenient(z.string().nullable()),
11
+ app_display_name: lenient(z.string()),
12
+ device_display_name: lenient(z.string()),
13
+ lang: lenient(z.string()),
14
+ data: lenient(z.looseObject({ url: lenient(z.string()) })),
15
+ append: z.unknown().optional()
16
+ });
17
+ function findRule(rules, kind, ruleId) {
18
+ if (!KINDS.includes(kind)) throw errors.invalidParam("Unknown rule kind");
19
+ const rule = rules[kind]?.find((r) => r.rule_id === ruleId);
20
+ if (!rule) throw errors.notFound("Unknown push rule");
21
+ return rule;
22
+ }
23
+ async function ruleCtx(ctx) {
24
+ const s = await ctx.auth();
25
+ if (ctx.params.scope !== "global") throw errors.invalidParam("Unknown scope");
26
+ return {
27
+ s,
28
+ rules: await loadRules(ctx.hs, s.userId)
29
+ };
30
+ }
31
+ function registerPushRoutes(r) {
32
+ r.client("GET", "/pushrules", async (ctx) => {
33
+ const s = await ctx.auth();
34
+ return { global: await loadRules(ctx.hs, s.userId) };
35
+ });
36
+ r.client("GET", "/pushrules/:scope", async (ctx) => {
37
+ const { rules } = await ruleCtx(ctx);
38
+ return rules;
39
+ });
40
+ r.client("GET", "/pushrules/:scope/:kind/:ruleId", async (ctx) => {
41
+ const { rules } = await ruleCtx(ctx);
42
+ return findRule(rules, ctx.params.kind, ctx.params.ruleId);
43
+ });
44
+ r.client("DELETE", "/pushrules/:scope/:kind/:ruleId", async (ctx) => withRulesLock(ctx.hs, (await ctx.auth()).userId, async () => {
45
+ const { s, rules } = await ruleCtx(ctx);
46
+ const rule = findRule(rules, ctx.params.kind, ctx.params.ruleId);
47
+ if (rule.default) throw errors.forbidden("Cannot delete a default rule");
48
+ rules[ctx.params.kind] = rules[ctx.params.kind].filter((x) => x !== rule);
49
+ await saveRules(ctx.hs, s.userId, rules);
50
+ return {};
51
+ }));
52
+ r.client("PUT", "/pushrules/:scope/:kind/:ruleId", async (ctx) => withRulesLock(ctx.hs, (await ctx.auth()).userId, async () => {
53
+ const { s, rules } = await ruleCtx(ctx);
54
+ const { kind, ruleId } = ctx.params;
55
+ if (!KINDS.includes(kind)) throw errors.invalidParam("Unknown rule kind");
56
+ if (ruleId.startsWith(".")) throw errors.invalidParam("Rule IDs starting with '.' are reserved");
57
+ const body = await ctx.json();
58
+ if (!Array.isArray(body.actions)) throw errors.badJson("actions must be a list");
59
+ const rule = {
60
+ rule_id: ruleId,
61
+ default: false,
62
+ enabled: true,
63
+ actions: body.actions
64
+ };
65
+ if (kind === "content") {
66
+ if (typeof body.pattern !== "string") throw errors.missingParam("Missing pattern");
67
+ rule.pattern = body.pattern;
68
+ }
69
+ if (kind === "override" || kind === "underride") rule.conditions = Array.isArray(body.conditions) ? body.conditions : [];
70
+ const list = (rules[kind] ??= []).filter((x) => x.rule_id !== ruleId);
71
+ const before = ctx.query.get("before");
72
+ const after = ctx.query.get("after");
73
+ let index = list.findIndex((x) => x.default && x.rule_id !== ".m.rule.master");
74
+ if (index === -1) index = list.length;
75
+ if (before) {
76
+ index = list.findIndex((x) => x.rule_id === before);
77
+ if (index === -1) throw errors.notFound("Unknown 'before' rule");
78
+ } else if (after) {
79
+ index = list.findIndex((x) => x.rule_id === after);
80
+ if (index === -1) throw errors.notFound("Unknown 'after' rule");
81
+ index++;
82
+ }
83
+ list.splice(index, 0, rule);
84
+ rules[kind] = list;
85
+ await saveRules(ctx.hs, s.userId, rules);
86
+ return {};
87
+ }));
88
+ for (const attr of ["enabled", "actions"]) {
89
+ r.client("GET", `/pushrules/:scope/:kind/:ruleId/${attr}`, async (ctx) => {
90
+ const { rules } = await ruleCtx(ctx);
91
+ return { [attr]: findRule(rules, ctx.params.kind, ctx.params.ruleId)[attr] };
92
+ });
93
+ r.client("PUT", `/pushrules/:scope/:kind/:ruleId/${attr}`, async (ctx) => withRulesLock(ctx.hs, (await ctx.auth()).userId, async () => {
94
+ const { s, rules } = await ruleCtx(ctx);
95
+ const rule = findRule(rules, ctx.params.kind, ctx.params.ruleId);
96
+ const body = await ctx.json();
97
+ if (attr === "enabled" && typeof body.enabled !== "boolean") throw errors.badJson("enabled must be a boolean");
98
+ if (attr === "actions" && !Array.isArray(body.actions)) throw errors.badJson("actions must be a list");
99
+ rule[attr] = body[attr];
100
+ await saveRules(ctx.hs, s.userId, rules);
101
+ return {};
102
+ }));
103
+ }
104
+ r.client("GET", "/pushers", async (ctx) => {
105
+ const s = await ctx.auth();
106
+ return { pushers: (await ctx.hs.db.findMany("pushers", { where: [{
107
+ field: "user_id",
108
+ value: s.userId
109
+ }] })).map((r) => r.pusher) };
110
+ });
111
+ r.client("POST", "/pushers/set", { input: pusherBody }, async (ctx) => {
112
+ const s = await ctx.auth();
113
+ const body = ctx.input;
114
+ if (body.kind === null) {
115
+ await ctx.hs.db.delete("pushers", [
116
+ {
117
+ field: "user_id",
118
+ value: s.userId
119
+ },
120
+ {
121
+ field: "app_id",
122
+ value: body.app_id
123
+ },
124
+ {
125
+ field: "pushkey",
126
+ value: body.pushkey
127
+ }
128
+ ]);
129
+ return {};
130
+ }
131
+ for (const k of [
132
+ "kind",
133
+ "app_display_name",
134
+ "device_display_name",
135
+ "lang"
136
+ ]) if (body[k] === void 0) throw errors.missingParam(`Missing ${k}`);
137
+ if (!body.data) throw errors.missingParam("Missing data");
138
+ if (body.kind === "http" && body.data.url === void 0) throw errors.invalidParam("data.url is required for http pushers");
139
+ if (!body.append) {
140
+ const others = await ctx.hs.db.findMany("pushers", { where: [{
141
+ field: "app_id",
142
+ value: body.app_id
143
+ }, {
144
+ field: "pushkey",
145
+ value: body.pushkey
146
+ }] });
147
+ for (const o of others) await ctx.hs.db.delete("pushers", [
148
+ {
149
+ field: "user_id",
150
+ value: o.user_id
151
+ },
152
+ {
153
+ field: "app_id",
154
+ value: o.app_id
155
+ },
156
+ {
157
+ field: "pushkey",
158
+ value: o.pushkey
159
+ }
160
+ ]);
161
+ }
162
+ const { append, ...pusher } = body;
163
+ await ctx.hs.db.upsert("pushers", {
164
+ user_id: s.userId,
165
+ app_id: body.app_id,
166
+ pushkey: body.pushkey,
167
+ device_id: s.deviceId,
168
+ pusher
169
+ });
170
+ return {};
171
+ });
172
+ r.client("GET", "/notifications", async (ctx) => {
173
+ const s = await ctx.auth();
174
+ const from = ctx.query.get("from");
175
+ const limit = Math.min(Math.max(Number.parseInt(ctx.query.get("limit") ?? "20", 10) || 20, 1), 100);
176
+ const rows = await ctx.hs.push.list(s.userId, {
177
+ from: from ? Number.parseInt(from, 10) : null,
178
+ limit,
179
+ onlyHighlight: ctx.query.get("only") === "highlight"
180
+ });
181
+ const out = { notifications: rows.map(({ stream, ...n }) => n) };
182
+ if (rows.length === limit) out.next_token = String(rows[rows.length - 1].stream);
183
+ return out;
184
+ });
185
+ }
186
+ //#endregion
187
+ export { registerPushRoutes };