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