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,483 @@
|
|
|
1
|
+
import { randomString } from "./crypto.js";
|
|
2
|
+
import { M, UNSTABLE } from "./m.js";
|
|
3
|
+
import { MatrixError } from "./errors.js";
|
|
4
|
+
import { isValidRoomId, isValidUserId } from "./ids.js";
|
|
5
|
+
import { createUser } from "../api/account.js";
|
|
6
|
+
//#region src/core/adminroom.ts
|
|
7
|
+
const HELP = `Admin commands (type them in this room, optionally prefixed with !admin):
|
|
8
|
+
|
|
9
|
+
users list [--all] List accounts (--all includes deactivated)
|
|
10
|
+
users info <user> Account details
|
|
11
|
+
users whois <user> Devices, IPs and last seen
|
|
12
|
+
users create <localpart> [password] [--admin]
|
|
13
|
+
users reset-password <user> [password] Generates a password when omitted; logs out all devices
|
|
14
|
+
users deactivate <user> [--keep-profile]
|
|
15
|
+
users make-admin <user> | users revoke-admin <user>
|
|
16
|
+
users lock <user> | users unlock <user> Lock: the account cannot be used until unlocked
|
|
17
|
+
users suspend <user> | users unsuspend <user> Suspend: read-only (can still leave rooms and log out)
|
|
18
|
+
tokens list | tokens create [uses] [days] | tokens delete <token>
|
|
19
|
+
registration status | open | close | token
|
|
20
|
+
rooms list | rooms info <room> | rooms delete <room> [--purge]
|
|
21
|
+
media delete <mxc://server/id> | media purge <days>
|
|
22
|
+
notice <user> <message> Send a server notice
|
|
23
|
+
audit [user] [limit] Recent audit log entries
|
|
24
|
+
reports list | reports resolve <id> Content reports
|
|
25
|
+
server stats
|
|
26
|
+
help`;
|
|
27
|
+
/** Split a command line into arguments, honouring quotes. */
|
|
28
|
+
function parseArgs(line) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const m of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3]);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A tuwunel-style admin room: server admins chat with an admin bot to manage the
|
|
35
|
+
* server. The first registered account becomes an admin and is put in the room.
|
|
36
|
+
*/
|
|
37
|
+
var AdminRoomService = class {
|
|
38
|
+
hs;
|
|
39
|
+
options;
|
|
40
|
+
roomIdCache = null;
|
|
41
|
+
constructor(hs, options) {
|
|
42
|
+
this.hs = hs;
|
|
43
|
+
this.options = {
|
|
44
|
+
enabled: options.enabled ?? true,
|
|
45
|
+
firstUserIsAdmin: options.firstUserIsAdmin ?? true,
|
|
46
|
+
firstUserCanRegister: options.firstUserCanRegister ?? true,
|
|
47
|
+
botLocalpart: options.botLocalpart ?? "admin",
|
|
48
|
+
botDisplayName: options.botDisplayName ?? "Admin",
|
|
49
|
+
roomName: options.roomName ?? "Admin Room",
|
|
50
|
+
aliasLocalpart: options.aliasLocalpart ?? "admins"
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
get botUserId() {
|
|
54
|
+
return `@${this.options.botLocalpart}:${this.hs.serverName}`;
|
|
55
|
+
}
|
|
56
|
+
get alias() {
|
|
57
|
+
return `#${this.options.aliasLocalpart}:${this.hs.serverName}`;
|
|
58
|
+
}
|
|
59
|
+
/** Whether no real (non-bot) account exists yet. */
|
|
60
|
+
async noUsersYet() {
|
|
61
|
+
return (await this.hs.db.findMany("users", { limit: 2 })).every((u) => u.user_id === this.botUserId);
|
|
62
|
+
}
|
|
63
|
+
async roomId() {
|
|
64
|
+
if (this.roomIdCache) return this.roomIdCache;
|
|
65
|
+
const row = await this.hs.db.findOne("room_aliases", [{
|
|
66
|
+
field: "alias",
|
|
67
|
+
value: this.alias
|
|
68
|
+
}]);
|
|
69
|
+
this.roomIdCache = row?.room_id ?? null;
|
|
70
|
+
return this.roomIdCache;
|
|
71
|
+
}
|
|
72
|
+
/** Create the admin bot and room if needed. */
|
|
73
|
+
async ensureRoom() {
|
|
74
|
+
return this.hs.cluster.lock("adminroom", async () => {
|
|
75
|
+
const existing = await this.roomId();
|
|
76
|
+
if (existing) return existing;
|
|
77
|
+
if (!await this.hs.db.findOne("users", [{
|
|
78
|
+
field: "user_id",
|
|
79
|
+
value: this.botUserId
|
|
80
|
+
}])) await createUser(this.hs, this.options.botLocalpart, {
|
|
81
|
+
displayname: this.options.botDisplayName,
|
|
82
|
+
admin: true
|
|
83
|
+
});
|
|
84
|
+
const { room_id } = await this.hs.rooms.createRoom(this.botUserId, {
|
|
85
|
+
preset: "private_chat",
|
|
86
|
+
name: this.options.roomName,
|
|
87
|
+
topic: "Server administration. Type 'help' for commands.",
|
|
88
|
+
room_alias_name: this.options.aliasLocalpart,
|
|
89
|
+
power_level_content_override: {
|
|
90
|
+
users: { [this.botUserId]: 100 },
|
|
91
|
+
events_default: 0,
|
|
92
|
+
invite: 100,
|
|
93
|
+
kick: 100,
|
|
94
|
+
ban: 100
|
|
95
|
+
},
|
|
96
|
+
initial_state: [{
|
|
97
|
+
type: M.ROOM.HISTORY_VISIBILITY,
|
|
98
|
+
state_key: "",
|
|
99
|
+
content: { history_visibility: "joined" }
|
|
100
|
+
}]
|
|
101
|
+
});
|
|
102
|
+
this.roomIdCache = room_id;
|
|
103
|
+
return room_id;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Called after an account was created. */
|
|
107
|
+
async onUserCreated(userId) {
|
|
108
|
+
if (!this.options.enabled || userId === this.botUserId || !this.options.firstUserIsAdmin) return;
|
|
109
|
+
if (!await this.hs.cluster.lock("first-user", async () => {
|
|
110
|
+
return (await this.hs.db.findMany("users", {
|
|
111
|
+
sortBy: {
|
|
112
|
+
field: "created_ts",
|
|
113
|
+
direction: "asc"
|
|
114
|
+
},
|
|
115
|
+
limit: 3
|
|
116
|
+
})).filter((u) => u.user_id !== this.botUserId)[0]?.user_id === userId;
|
|
117
|
+
})) return;
|
|
118
|
+
await this.hs.db.update("users", [{
|
|
119
|
+
field: "user_id",
|
|
120
|
+
value: userId
|
|
121
|
+
}], { is_admin: true });
|
|
122
|
+
await this.hs.auth.invalidateUser(userId);
|
|
123
|
+
await this.addAdmin(userId);
|
|
124
|
+
await this.say(`Welcome ${userId}! As the first user you are a server admin. Type \`help\` for the available commands.`);
|
|
125
|
+
}
|
|
126
|
+
/** Put an admin into the admin room. */
|
|
127
|
+
async addAdmin(userId) {
|
|
128
|
+
if (!this.options.enabled || userId === this.botUserId) return;
|
|
129
|
+
const roomId = await this.ensureRoom();
|
|
130
|
+
const membership = await this.hs.rooms.membership(roomId, userId);
|
|
131
|
+
if (membership === "join") return;
|
|
132
|
+
if (membership !== "invite") await this.hs.rooms.invite(roomId, this.botUserId, userId);
|
|
133
|
+
await this.hs.rooms.join(userId, roomId);
|
|
134
|
+
}
|
|
135
|
+
/** Remove a former admin from the admin room. */
|
|
136
|
+
async removeAdmin(userId) {
|
|
137
|
+
const roomId = await this.roomId();
|
|
138
|
+
if (!roomId || userId === this.botUserId) return;
|
|
139
|
+
const membership = await this.hs.rooms.membership(roomId, userId);
|
|
140
|
+
if (membership === "join" || membership === "invite") await this.hs.rooms.setMembership(roomId, this.botUserId, userId, "leave", "No longer a server admin");
|
|
141
|
+
}
|
|
142
|
+
/** Post a message from the admin bot. */
|
|
143
|
+
async say(text, replyTo) {
|
|
144
|
+
const roomId = await this.roomId();
|
|
145
|
+
if (!roomId) return;
|
|
146
|
+
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
147
|
+
const content = {
|
|
148
|
+
msgtype: M.NOTICE,
|
|
149
|
+
body: text,
|
|
150
|
+
format: UNSTABLE.ORG.MATRIX.CUSTOM.HTML,
|
|
151
|
+
formatted_body: text.includes("\n") ? `<pre><code>${escaped}</code></pre>` : escaped.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
152
|
+
};
|
|
153
|
+
if (replyTo) content[M.RELATES_TO] = { [M.IN_REPLY_TO]: { event_id: replyTo } };
|
|
154
|
+
await this.hs.rooms.sendEvent(roomId, this.botUserId, {
|
|
155
|
+
type: M.ROOM.MESSAGE,
|
|
156
|
+
content
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Called for every new event; handles commands posted in the admin room. */
|
|
160
|
+
async onEvent(event) {
|
|
161
|
+
if (!this.options.enabled || event.type !== M.ROOM.MESSAGE || event.sender === this.botUserId) return;
|
|
162
|
+
if (event.room_id !== await this.roomId()) return;
|
|
163
|
+
const body = typeof event.content?.body === "string" ? event.content.body.trim() : "";
|
|
164
|
+
if (!body || event.content?.msgtype === M.NOTICE) return;
|
|
165
|
+
if (!(await this.hs.db.findOne("users", [{
|
|
166
|
+
field: "user_id",
|
|
167
|
+
value: event.sender
|
|
168
|
+
}]))?.is_admin) return;
|
|
169
|
+
const line = body.replace(/^!admin\s*/i, "").replace(new RegExp(`^${this.botUserId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:?\\s*`), "");
|
|
170
|
+
let reply;
|
|
171
|
+
try {
|
|
172
|
+
reply = await this.execute(event.sender, parseArgs(line));
|
|
173
|
+
} catch (err) {
|
|
174
|
+
reply = `Error: ${err instanceof MatrixError ? err.message : err instanceof Error ? err.message : String(err)}`;
|
|
175
|
+
}
|
|
176
|
+
await this.hs.audit.record({
|
|
177
|
+
action: "admin_command",
|
|
178
|
+
actor: event.sender,
|
|
179
|
+
details: { command: line.split(/\s+/).slice(0, 2).join(" ") }
|
|
180
|
+
});
|
|
181
|
+
await this.say(reply, event.event_id);
|
|
182
|
+
}
|
|
183
|
+
resolveUser(arg) {
|
|
184
|
+
if (!arg) throw new Error("Missing user");
|
|
185
|
+
const id = arg.startsWith("@") ? arg : `@${arg.toLowerCase()}:${this.hs.serverName}`;
|
|
186
|
+
if (!isValidUserId(id)) throw new Error(`Invalid user ID: ${arg}`);
|
|
187
|
+
return id;
|
|
188
|
+
}
|
|
189
|
+
async resolveRoom(arg) {
|
|
190
|
+
if (!arg) throw new Error("Missing room");
|
|
191
|
+
if (arg.startsWith("#")) return this.hs.rooms.resolveRoomIdOrAlias(arg);
|
|
192
|
+
if (!isValidRoomId(arg)) throw new Error(`Invalid room: ${arg}`);
|
|
193
|
+
return arg;
|
|
194
|
+
}
|
|
195
|
+
/** Run one admin command; returns the reply text. */
|
|
196
|
+
async execute(actor, args) {
|
|
197
|
+
const hs = this.hs;
|
|
198
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
199
|
+
const pos = args.filter((a) => !a.startsWith("--"));
|
|
200
|
+
const [group, sub, a1, a2] = pos;
|
|
201
|
+
const time = (ts) => ts ? new Date(ts).toISOString() : "never";
|
|
202
|
+
switch (group?.toLowerCase()) {
|
|
203
|
+
case void 0:
|
|
204
|
+
case "help": return HELP;
|
|
205
|
+
case "users":
|
|
206
|
+
switch (sub) {
|
|
207
|
+
case "list": {
|
|
208
|
+
const { users, total } = await hs.admin.listUsers({
|
|
209
|
+
includeDeactivated: flags.has("--all"),
|
|
210
|
+
limit: 200
|
|
211
|
+
});
|
|
212
|
+
return `${total} account(s):\n${users.map((u) => `${u.user_id}${u.is_admin ? " [admin]" : ""}${u.deactivated ? " [deactivated]" : ""}${u.locked ? " [locked]" : ""}${u.suspended ? " [suspended]" : ""}`).join("\n")}`;
|
|
213
|
+
}
|
|
214
|
+
case "info": {
|
|
215
|
+
const user = await hs.admin.getUser(this.resolveUser(a1));
|
|
216
|
+
if (!user) return "No such user";
|
|
217
|
+
return Object.entries(user).map(([k, v]) => `${k}: ${k.endsWith("_ts") ? time(v) : v}`).join("\n");
|
|
218
|
+
}
|
|
219
|
+
case "whois": {
|
|
220
|
+
const userId = this.resolveUser(a1);
|
|
221
|
+
const devices = await hs.db.findMany("devices", { where: [{
|
|
222
|
+
field: "user_id",
|
|
223
|
+
value: userId
|
|
224
|
+
}] });
|
|
225
|
+
if (!devices.length) return `${userId} has no devices`;
|
|
226
|
+
return devices.map((d) => `${d.device_id} ${d.display_name ?? ""}\n last seen ${time(d.last_seen_ts)} from ${d.last_seen_ip ?? "unknown"}`).join("\n");
|
|
227
|
+
}
|
|
228
|
+
case "create": {
|
|
229
|
+
if (!a1) return "Usage: users create <localpart> [password] [--admin]";
|
|
230
|
+
const password = a2 ?? randomString(16);
|
|
231
|
+
const userId = await hs.admin.createUser(a1, {
|
|
232
|
+
password,
|
|
233
|
+
admin: flags.has("--admin")
|
|
234
|
+
});
|
|
235
|
+
if (flags.has("--admin")) await this.addAdmin(userId);
|
|
236
|
+
await hs.audit.record({
|
|
237
|
+
action: "admin_action",
|
|
238
|
+
actor,
|
|
239
|
+
target: userId,
|
|
240
|
+
details: {
|
|
241
|
+
op: "create_user",
|
|
242
|
+
admin: flags.has("--admin")
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return `Created ${userId}${a2 ? "" : ` with password: ${password}`}`;
|
|
246
|
+
}
|
|
247
|
+
case "reset-password": {
|
|
248
|
+
const userId = this.resolveUser(a1);
|
|
249
|
+
const password = a2 ?? randomString(16);
|
|
250
|
+
await hs.admin.resetPassword(userId, password, true);
|
|
251
|
+
await hs.audit.record({
|
|
252
|
+
action: "admin_action",
|
|
253
|
+
actor,
|
|
254
|
+
target: userId,
|
|
255
|
+
details: { op: "reset_password" }
|
|
256
|
+
});
|
|
257
|
+
return `Password for ${userId} reset${a2 ? "" : ` to: ${password}`}. All devices were logged out.`;
|
|
258
|
+
}
|
|
259
|
+
case "deactivate": {
|
|
260
|
+
const userId = this.resolveUser(a1);
|
|
261
|
+
if (userId === actor) return "You cannot deactivate yourself here";
|
|
262
|
+
await hs.admin.deactivateUser(userId, { erase: !flags.has("--keep-profile") });
|
|
263
|
+
await hs.audit.record({
|
|
264
|
+
action: "admin_action",
|
|
265
|
+
actor,
|
|
266
|
+
target: userId,
|
|
267
|
+
details: { op: "deactivate" }
|
|
268
|
+
});
|
|
269
|
+
return `Deactivated ${userId}`;
|
|
270
|
+
}
|
|
271
|
+
case "make-admin":
|
|
272
|
+
case "revoke-admin": {
|
|
273
|
+
const userId = this.resolveUser(a1);
|
|
274
|
+
if (sub === "revoke-admin" && userId === actor) return "You cannot revoke your own admin rights";
|
|
275
|
+
await hs.admin.setAdmin(userId, sub === "make-admin");
|
|
276
|
+
await hs.audit.record({
|
|
277
|
+
action: "admin_action",
|
|
278
|
+
actor,
|
|
279
|
+
target: userId,
|
|
280
|
+
details: { op: sub }
|
|
281
|
+
});
|
|
282
|
+
return `${userId} is ${sub === "make-admin" ? "now" : "no longer"} a server admin`;
|
|
283
|
+
}
|
|
284
|
+
case "lock":
|
|
285
|
+
case "unlock": {
|
|
286
|
+
const userId = this.resolveUser(a1);
|
|
287
|
+
if (userId === actor) return "You cannot lock yourself";
|
|
288
|
+
await hs.admin.setLocked(userId, sub === "lock", actor);
|
|
289
|
+
return `${userId} is ${sub === "lock" ? "now" : "no longer"} locked`;
|
|
290
|
+
}
|
|
291
|
+
case "suspend":
|
|
292
|
+
case "unsuspend": {
|
|
293
|
+
const userId = this.resolveUser(a1);
|
|
294
|
+
if (userId === actor) return "You cannot suspend yourself";
|
|
295
|
+
await hs.admin.setSuspended(userId, sub === "suspend", actor);
|
|
296
|
+
return `${userId} is ${sub === "suspend" ? "now" : "no longer"} suspended`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return "Usage: users list|info|whois|create|reset-password|deactivate|make-admin|revoke-admin|lock|unlock|suspend|unsuspend";
|
|
300
|
+
case "tokens":
|
|
301
|
+
switch (sub) {
|
|
302
|
+
case "list": {
|
|
303
|
+
const tokens = await hs.admin.listRegistrationTokens();
|
|
304
|
+
if (!tokens.length) return "No registration tokens";
|
|
305
|
+
return tokens.map((t) => `${t.token} used ${t.completed}/${t.uses_allowed ?? "∞"} expires ${time(t.expiry_time)}`).join("\n");
|
|
306
|
+
}
|
|
307
|
+
case "create": {
|
|
308
|
+
const uses = a1 ? Number(a1) : null;
|
|
309
|
+
const days = a2 ? Number(a2) : null;
|
|
310
|
+
const t = await hs.admin.createRegistrationToken({
|
|
311
|
+
usesAllowed: uses,
|
|
312
|
+
expiryTime: days ? Date.now() + days * 864e5 : null
|
|
313
|
+
});
|
|
314
|
+
await hs.audit.record({
|
|
315
|
+
action: "registration_token_create",
|
|
316
|
+
actor,
|
|
317
|
+
target: t.token
|
|
318
|
+
});
|
|
319
|
+
return `Registration token: ${t.token}${uses ? ` (${uses} uses)` : ""}${days ? ` (expires in ${days} days)` : ""}`;
|
|
320
|
+
}
|
|
321
|
+
case "delete":
|
|
322
|
+
if (!a1) return "Usage: tokens delete <token>";
|
|
323
|
+
await hs.admin.deleteRegistrationToken(a1);
|
|
324
|
+
await hs.audit.record({
|
|
325
|
+
action: "registration_token_delete",
|
|
326
|
+
actor,
|
|
327
|
+
target: a1
|
|
328
|
+
});
|
|
329
|
+
return `Deleted token ${a1}`;
|
|
330
|
+
}
|
|
331
|
+
return "Usage: tokens list|create [uses] [days]|delete <token>";
|
|
332
|
+
case "registration": {
|
|
333
|
+
if (!sub || sub === "status") return `Registration is ${await hs.registrationMode()}`;
|
|
334
|
+
if (![
|
|
335
|
+
"open",
|
|
336
|
+
"close",
|
|
337
|
+
"closed",
|
|
338
|
+
"token"
|
|
339
|
+
].includes(sub)) return "Usage: registration status|open|close|token";
|
|
340
|
+
const mode = sub === "close" ? "closed" : sub;
|
|
341
|
+
await hs.settings.set("registration", mode);
|
|
342
|
+
await hs.audit.record({
|
|
343
|
+
action: "registration_setting",
|
|
344
|
+
actor,
|
|
345
|
+
details: { mode }
|
|
346
|
+
});
|
|
347
|
+
return `Registration is now ${mode}`;
|
|
348
|
+
}
|
|
349
|
+
case "rooms":
|
|
350
|
+
switch (sub) {
|
|
351
|
+
case "list": {
|
|
352
|
+
const { rooms, total } = await hs.admin.listRooms({ limit: 200 });
|
|
353
|
+
return `${total} room(s):\n${(await Promise.all(rooms.map(async (r) => {
|
|
354
|
+
const name = (await hs.rooms.currentState(r.room_id)).get("m.room.name\0")?.content?.name;
|
|
355
|
+
return `${r.room_id} ${r.joined_members} member(s)${name ? ` "${name}"` : ""}${r.is_public ? " [public]" : ""}`;
|
|
356
|
+
}))).join("\n")}`;
|
|
357
|
+
}
|
|
358
|
+
case "info": {
|
|
359
|
+
const roomId = await this.resolveRoom(a1);
|
|
360
|
+
const state = await hs.rooms.currentState(roomId);
|
|
361
|
+
const members = await hs.rooms.joinedMembers(roomId);
|
|
362
|
+
return [
|
|
363
|
+
`Room ${roomId} (version ${await hs.rooms.roomVersion(roomId)})`,
|
|
364
|
+
`Name: ${state.get("m.room.name\0")?.content?.name ?? "-"}`,
|
|
365
|
+
`Creator: ${state.get("m.room.create\0")?.sender}`,
|
|
366
|
+
`Join rule: ${state.get("m.room.join_rules\0")?.content?.join_rule ?? "invite"}`,
|
|
367
|
+
`Members (${members.length}): ${members.slice(0, 50).join(", ")}${members.length > 50 ? ", …" : ""}`
|
|
368
|
+
].join("\n");
|
|
369
|
+
}
|
|
370
|
+
case "delete": {
|
|
371
|
+
const roomId = await this.resolveRoom(a1);
|
|
372
|
+
if (roomId === await this.roomId()) return "Refusing to delete the admin room";
|
|
373
|
+
const { kicked } = await hs.admin.deleteRoom(roomId, {
|
|
374
|
+
actor,
|
|
375
|
+
purge: flags.has("--purge"),
|
|
376
|
+
reason: "Room deleted by a server admin"
|
|
377
|
+
});
|
|
378
|
+
await hs.audit.record({
|
|
379
|
+
action: "room_delete",
|
|
380
|
+
actor,
|
|
381
|
+
target: roomId,
|
|
382
|
+
details: { purge: flags.has("--purge") }
|
|
383
|
+
});
|
|
384
|
+
return `Deleted ${roomId}; removed ${kicked.length} local member(s)${flags.has("--purge") ? " and purged its history" : ""}`;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return "Usage: rooms list|info <room>|delete <room> [--purge]";
|
|
388
|
+
case "media":
|
|
389
|
+
if (sub === "delete") {
|
|
390
|
+
const m = /^mxc:\/\/([^/]+)\/(.+)$/.exec(a1 ?? "");
|
|
391
|
+
if (!m || m[1] !== hs.serverName) return "Usage: media delete mxc://<this server>/<id>";
|
|
392
|
+
await hs.admin.deleteMedia(m[2]);
|
|
393
|
+
await hs.audit.record({
|
|
394
|
+
action: "admin_action",
|
|
395
|
+
actor,
|
|
396
|
+
target: a1,
|
|
397
|
+
details: { op: "delete_media" }
|
|
398
|
+
});
|
|
399
|
+
return `Deleted ${a1}`;
|
|
400
|
+
}
|
|
401
|
+
if (sub === "purge") {
|
|
402
|
+
const days = Number(a1);
|
|
403
|
+
if (!(days > 0)) return "Usage: media purge <days>";
|
|
404
|
+
const deleted = await hs.admin.purgeMediaBefore(Date.now() - days * 864e5);
|
|
405
|
+
await hs.audit.record({
|
|
406
|
+
action: "admin_action",
|
|
407
|
+
actor,
|
|
408
|
+
details: {
|
|
409
|
+
op: "purge_media",
|
|
410
|
+
days,
|
|
411
|
+
count: deleted.length
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
return `Deleted ${deleted.length} media file(s) older than ${days} day(s)`;
|
|
415
|
+
}
|
|
416
|
+
return "Usage: media delete <mxc>|purge <days>";
|
|
417
|
+
case "notice": {
|
|
418
|
+
const target = this.resolveUser(sub);
|
|
419
|
+
const message = pos.slice(2).join(" ");
|
|
420
|
+
if (!message) return "Usage: notice <user> <message>";
|
|
421
|
+
await hs.admin.sendServerNotice(target, {
|
|
422
|
+
msgtype: M.TEXT,
|
|
423
|
+
body: message
|
|
424
|
+
});
|
|
425
|
+
await hs.audit.record({
|
|
426
|
+
action: "admin_action",
|
|
427
|
+
actor,
|
|
428
|
+
target,
|
|
429
|
+
details: { op: "server_notice" }
|
|
430
|
+
});
|
|
431
|
+
return `Notice sent to ${target}`;
|
|
432
|
+
}
|
|
433
|
+
case "audit": {
|
|
434
|
+
if (!hs.audit.enabled) return "The audit log is disabled (enable it with the `audit` option)";
|
|
435
|
+
const user = sub && !/^\d+$/.test(sub) ? this.resolveUser(sub) : void 0;
|
|
436
|
+
const limit = Number(user ? a1 : sub) || 20;
|
|
437
|
+
const rows = await hs.audit.query({
|
|
438
|
+
actor: user,
|
|
439
|
+
limit
|
|
440
|
+
});
|
|
441
|
+
if (!rows.length) return "No audit entries";
|
|
442
|
+
return rows.map((r) => `${time(r.ts)} ${r.action}${r.outcome === "failure" ? " (failed)" : ""} by ${r.actor ?? "-"}${r.target ? ` → ${r.target}` : ""}${r.ip ? ` from ${r.ip}` : ""}`).join("\n");
|
|
443
|
+
}
|
|
444
|
+
case "reports": {
|
|
445
|
+
if (sub === "resolve") {
|
|
446
|
+
if (!await hs.db.update("event_reports", [{
|
|
447
|
+
field: "id",
|
|
448
|
+
value: Number(a1)
|
|
449
|
+
}], { resolved: true })) return "No such report";
|
|
450
|
+
return `Report ${a1} resolved`;
|
|
451
|
+
}
|
|
452
|
+
const rows = await hs.db.findMany("event_reports", {
|
|
453
|
+
where: [{
|
|
454
|
+
field: "resolved",
|
|
455
|
+
value: false
|
|
456
|
+
}],
|
|
457
|
+
sortBy: {
|
|
458
|
+
field: "id",
|
|
459
|
+
direction: "desc"
|
|
460
|
+
},
|
|
461
|
+
limit: 50
|
|
462
|
+
});
|
|
463
|
+
if (!rows.length) return "No open reports";
|
|
464
|
+
return rows.map((r) => `#${r.id} ${time(r.ts)} ${r.reporter} reported ${r.event_id ?? "the room"} in ${r.room_id}: ${r.reason ?? "(no reason)"}`).join("\n");
|
|
465
|
+
}
|
|
466
|
+
case "server": {
|
|
467
|
+
const users = await hs.db.findMany("users");
|
|
468
|
+
const rooms = await hs.db.findMany("rooms");
|
|
469
|
+
return [
|
|
470
|
+
`Server: ${hs.serverName}`,
|
|
471
|
+
`Users: ${users.filter((u) => !u.deactivated).length} active, ${users.filter((u) => u.deactivated).length} deactivated`,
|
|
472
|
+
`Rooms: ${rooms.length}`,
|
|
473
|
+
`Registration: ${await hs.registrationMode()}`,
|
|
474
|
+
`Federation: ${hs.federation.enabled ? "enabled" : "disabled"}`,
|
|
475
|
+
`Instance: ${hs.instanceId}`
|
|
476
|
+
].join("\n");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return `Unknown command "${group}". Type help for the list of commands.`;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
//#endregion
|
|
483
|
+
export { AdminRoomService, parseArgs };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { PDU } from "./events.js";
|
|
2
|
+
import { StateMap } from "./authrules.js";
|
|
3
|
+
import { UNSTABLE } from "./m.js";
|
|
4
|
+
import { Homeserver } from "../homeserver.js";
|
|
5
|
+
//#region src/core/appservices.d.ts
|
|
6
|
+
export interface AppServiceNamespace {
|
|
7
|
+
exclusive: boolean;
|
|
8
|
+
regex: string;
|
|
9
|
+
}
|
|
10
|
+
/** An application service registration (bridges, bots, integrations). */
|
|
11
|
+
export interface AppServiceRegistration {
|
|
12
|
+
id: string;
|
|
13
|
+
/** Base URL of the application service, or null when it receives nothing. */
|
|
14
|
+
url: string | null;
|
|
15
|
+
as_token: string;
|
|
16
|
+
hs_token: string;
|
|
17
|
+
sender_localpart: string;
|
|
18
|
+
namespaces: {
|
|
19
|
+
users?: AppServiceNamespace[];
|
|
20
|
+
aliases?: AppServiceNamespace[];
|
|
21
|
+
rooms?: AppServiceNamespace[];
|
|
22
|
+
};
|
|
23
|
+
rate_limited?: boolean;
|
|
24
|
+
protocols?: string[];
|
|
25
|
+
/** MSC2409: also push ephemeral events (typing, receipts, presence). */
|
|
26
|
+
[UNSTABLE.DE.SORUNOME.MSC2409.PUSH_EPHEMERAL]?: boolean;
|
|
27
|
+
receive_ephemeral?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse an application service registration file. Accepts JSON or the YAML subset
|
|
31
|
+
* used by registration files (nested mappings and lists of mappings, scalars).
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseAppServiceRegistration(text: string): AppServiceRegistration;
|
|
34
|
+
/** Load every *.yaml / *.yml / *.json registration in a directory. */
|
|
35
|
+
export declare function loadAppServiceRegistrations(directory: string): Promise<AppServiceRegistration[]>;
|
|
36
|
+
/** Application Service API: authentication, namespaces, event push and queries. */
|
|
37
|
+
export declare class AppServiceService {
|
|
38
|
+
private hs;
|
|
39
|
+
private services;
|
|
40
|
+
private timers;
|
|
41
|
+
constructor(hs: Homeserver, registrations: AppServiceRegistration[]);
|
|
42
|
+
get registrations(): AppServiceRegistration[];
|
|
43
|
+
byToken(token: string): AppServiceRegistration | null;
|
|
44
|
+
byId(id: string): AppServiceRegistration | null;
|
|
45
|
+
senderOf(reg: AppServiceRegistration): string;
|
|
46
|
+
private compiled;
|
|
47
|
+
/** Whether the AS owns this user ID (its sender or its user namespace). */
|
|
48
|
+
isInterestedUser(reg: AppServiceRegistration, userId: string): boolean;
|
|
49
|
+
/** An exclusive namespace owned by some AS (other than `except`) covers this user ID. */
|
|
50
|
+
exclusiveUserOwner(userId: string): AppServiceRegistration | null;
|
|
51
|
+
exclusiveAliasOwner(alias: string): AppServiceRegistration | null;
|
|
52
|
+
/** Resolve the user an AS request acts as (masquerading with `user_id`). */
|
|
53
|
+
masquerade(reg: AppServiceRegistration, userIdParam: string | null): Promise<string>;
|
|
54
|
+
private interestedInEvent;
|
|
55
|
+
/** Queue an event for every interested application service. */
|
|
56
|
+
onEvent(pdu: PDU, state: StateMap): Promise<void>;
|
|
57
|
+
/** Queue an ephemeral event (typing/receipt/presence) for interested services that asked for them. */
|
|
58
|
+
onEphemeral(roomId: string | null, event: Record<string, any>, users: string[]): Promise<void>;
|
|
59
|
+
private enqueue;
|
|
60
|
+
private schedule;
|
|
61
|
+
private retries;
|
|
62
|
+
private flush;
|
|
63
|
+
start(): void;
|
|
64
|
+
stop(): void;
|
|
65
|
+
private query;
|
|
66
|
+
/** Ask the owning AS whether a user exists (it may create it). */
|
|
67
|
+
queryUser(userId: string): Promise<boolean>;
|
|
68
|
+
/** Ask the owning AS about an alias; returns the room ID if it now exists. */
|
|
69
|
+
queryAlias(alias: string): Promise<string | null>;
|
|
70
|
+
ping(reg: AppServiceRegistration, transactionId?: string): Promise<number>;
|
|
71
|
+
thirdParty(path: string, query: URLSearchParams): Promise<unknown[]>;
|
|
72
|
+
protocols(): Promise<Record<string, unknown>>;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|