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.
- package/LICENSE +75 -0
- package/README.md +457 -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 +560 -0
- package/dist/api/admin.js +251 -0
- package/dist/api/appservice.js +33 -0
- package/dist/api/backup.js +269 -0
- package/dist/api/federation.js +792 -0
- package/dist/api/filters.js +87 -0
- package/dist/api/index.js +47 -0
- package/dist/api/legacy.js +170 -0
- package/dist/api/media.js +255 -0
- package/dist/api/misc.js +86 -0
- package/dist/api/preview.js +200 -0
- package/dist/api/profile.js +165 -0
- package/dist/api/push.js +187 -0
- package/dist/api/relationships.js +264 -0
- package/dist/api/rooms.js +1364 -0
- package/dist/api/rtc.d.ts +24 -0
- package/dist/api/rtc.js +52 -0
- package/dist/api/search.js +155 -0
- package/dist/api/slidingsync.js +464 -0
- package/dist/api/spaces.js +172 -0
- package/dist/api/sync.js +600 -0
- package/dist/api/threadsubs.js +129 -0
- package/dist/api/threepid.js +122 -0
- package/dist/api/uia.js +126 -0
- package/dist/api/user.js +286 -0
- package/dist/core/admin.d.ts +85 -0
- package/dist/core/admin.js +307 -0
- package/dist/core/adminroom.d.ts +49 -0
- package/dist/core/adminroom.js +482 -0
- package/dist/core/appservices.d.ts +73 -0
- package/dist/core/appservices.js +368 -0
- package/dist/core/audit.d.ts +51 -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 +347 -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 +159 -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 +185 -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 +35 -0
- package/dist/core/json.js +16 -0
- package/dist/core/keys.d.ts +36 -0
- package/dist/core/keys.js +449 -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 +197 -0
- package/dist/core/phone.js +268 -0
- package/dist/core/policy.d.ts +29 -0
- package/dist/core/policy.js +96 -0
- package/dist/core/profile.js +39 -0
- package/dist/core/push.d.ts +47 -0
- package/dist/core/push.js +306 -0
- package/dist/core/pushrules.d.ts +1 -0
- package/dist/core/pushrules.js +228 -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 +1729 -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 +208 -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 +278 -0
- package/dist/federation/index.d.ts +153 -0
- package/dist/federation/index.js +1058 -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 +223 -0
- package/dist/homeserver.js +301 -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 +202 -0
- package/dist/http/input.js +24 -0
- package/dist/http/router.d.ts +60 -0
- package/dist/http/router.js +76 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +14 -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/package.json +67 -4
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { localCluster } from "../core/cluster.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
//#region src/adapters/cluster.ts
|
|
4
|
+
/** Per-process queue so each instance holds at most one remote lock per key. */
|
|
5
|
+
function localQueue() {
|
|
6
|
+
const tails = /* @__PURE__ */ new Map();
|
|
7
|
+
return async (key, fn) => {
|
|
8
|
+
const run = (tails.get(key) ?? Promise.resolve()).then(fn, fn);
|
|
9
|
+
const settled = run.catch(() => {});
|
|
10
|
+
tails.set(key, settled);
|
|
11
|
+
try {
|
|
12
|
+
return await run;
|
|
13
|
+
} finally {
|
|
14
|
+
if (tails.get(key) === settled) tails.delete(key);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const PG_CHANNEL = "matrix_cluster";
|
|
19
|
+
const MAX_NOTIFY_PAYLOAD = 7e3;
|
|
20
|
+
/**
|
|
21
|
+
* Cluster adapter backed by PostgreSQL (`pg` Pool): sequences, advisory locks,
|
|
22
|
+
* LISTEN/NOTIFY pub/sub and an expiring key-value table. Lets several homeserver
|
|
23
|
+
* instances share one database.
|
|
24
|
+
*/
|
|
25
|
+
function postgresCluster(pool, options = {}) {
|
|
26
|
+
const seq = `"${options.tablePrefix ?? ""}cluster_sequences"`;
|
|
27
|
+
const kv = `"${options.tablePrefix ?? ""}cluster_kv"`;
|
|
28
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
29
|
+
const queue = localQueue();
|
|
30
|
+
let listener = null;
|
|
31
|
+
let cleanup = null;
|
|
32
|
+
const ready = (async () => {
|
|
33
|
+
const setup = await pool.connect();
|
|
34
|
+
try {
|
|
35
|
+
await setup.query("BEGIN");
|
|
36
|
+
await setup.query("SELECT pg_advisory_xact_lock(hashtextextended('matrix_cluster_setup', 0))");
|
|
37
|
+
await setup.query(`CREATE TABLE IF NOT EXISTS ${seq} (name TEXT PRIMARY KEY, value BIGINT NOT NULL)`);
|
|
38
|
+
await setup.query(`CREATE TABLE IF NOT EXISTS ${kv} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at BIGINT)`);
|
|
39
|
+
await setup.query("COMMIT");
|
|
40
|
+
} catch (err) {
|
|
41
|
+
await setup.query("ROLLBACK").catch(() => {});
|
|
42
|
+
throw err;
|
|
43
|
+
} finally {
|
|
44
|
+
setup.release();
|
|
45
|
+
}
|
|
46
|
+
listener = await pool.connect();
|
|
47
|
+
listener.on("notification", (msg) => {
|
|
48
|
+
if (msg.channel !== PG_CHANNEL || !msg.payload) return;
|
|
49
|
+
(async () => {
|
|
50
|
+
let parsed = JSON.parse(msg.payload);
|
|
51
|
+
if (parsed.ref) {
|
|
52
|
+
const res = await pool.query(`SELECT value FROM ${kv} WHERE key = $1`, [parsed.ref]);
|
|
53
|
+
if (!res.rows[0]) return;
|
|
54
|
+
parsed = JSON.parse(res.rows[0].value);
|
|
55
|
+
}
|
|
56
|
+
for (const h of handlers.get(parsed.channel) ?? []) h(parsed.message);
|
|
57
|
+
})().catch(() => {});
|
|
58
|
+
});
|
|
59
|
+
await listener.query(`LISTEN ${PG_CHANNEL}`);
|
|
60
|
+
cleanup = setInterval(() => void pool.query(`DELETE FROM ${kv} WHERE expires_at IS NOT NULL AND expires_at < $1`, [Date.now()]).catch(() => {}), 6e4);
|
|
61
|
+
cleanup.unref?.();
|
|
62
|
+
})();
|
|
63
|
+
ready.catch(() => {});
|
|
64
|
+
const q = async (text, values) => {
|
|
65
|
+
await ready;
|
|
66
|
+
return pool.query(text, values);
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
async nextId(name) {
|
|
70
|
+
const res = await q(`INSERT INTO ${seq} (name, value) VALUES ($1, 1) ON CONFLICT (name) DO UPDATE SET value = ${seq}.value + 1 RETURNING value`, [name]);
|
|
71
|
+
return Number(res.rows[0].value);
|
|
72
|
+
},
|
|
73
|
+
async currentId(name) {
|
|
74
|
+
const res = await q(`SELECT value FROM ${seq} WHERE name = $1`, [name]);
|
|
75
|
+
return res.rows[0] ? Number(res.rows[0].value) : 0;
|
|
76
|
+
},
|
|
77
|
+
async advance(name, value) {
|
|
78
|
+
await q(`INSERT INTO ${seq} (name, value) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET value = GREATEST(${seq}.value, EXCLUDED.value)`, [name, value]);
|
|
79
|
+
},
|
|
80
|
+
lock(key, fn) {
|
|
81
|
+
return queue(key, async () => {
|
|
82
|
+
await ready;
|
|
83
|
+
const client = await pool.connect();
|
|
84
|
+
try {
|
|
85
|
+
await client.query("SELECT pg_advisory_lock(hashtextextended($1, 0))", [key]);
|
|
86
|
+
try {
|
|
87
|
+
return await fn();
|
|
88
|
+
} finally {
|
|
89
|
+
await client.query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
client.release();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
async publish(channel, message) {
|
|
97
|
+
let payload = JSON.stringify({
|
|
98
|
+
channel,
|
|
99
|
+
message
|
|
100
|
+
});
|
|
101
|
+
if (payload.length > MAX_NOTIFY_PAYLOAD) {
|
|
102
|
+
const ref = `pubsub:${randomUUID()}`;
|
|
103
|
+
await q(`INSERT INTO ${kv} (key, value, expires_at) VALUES ($1, $2, $3)`, [
|
|
104
|
+
ref,
|
|
105
|
+
payload,
|
|
106
|
+
Date.now() + 6e4
|
|
107
|
+
]);
|
|
108
|
+
payload = JSON.stringify({ ref });
|
|
109
|
+
}
|
|
110
|
+
await q("SELECT pg_notify($1, $2)", [PG_CHANNEL, payload]);
|
|
111
|
+
},
|
|
112
|
+
subscribe(channel, handler) {
|
|
113
|
+
let set = handlers.get(channel);
|
|
114
|
+
if (!set) handlers.set(channel, set = /* @__PURE__ */ new Set());
|
|
115
|
+
set.add(handler);
|
|
116
|
+
},
|
|
117
|
+
async get(key) {
|
|
118
|
+
const res = await q(`SELECT value FROM ${kv} WHERE key = $1 AND (expires_at IS NULL OR expires_at > $2)`, [key, Date.now()]);
|
|
119
|
+
return res.rows[0] ? JSON.parse(res.rows[0].value) : null;
|
|
120
|
+
},
|
|
121
|
+
async set(key, value, ttlMs) {
|
|
122
|
+
await q(`INSERT INTO ${kv} (key, value, expires_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`, [
|
|
123
|
+
key,
|
|
124
|
+
JSON.stringify(value),
|
|
125
|
+
ttlMs ? Date.now() + ttlMs : null
|
|
126
|
+
]);
|
|
127
|
+
},
|
|
128
|
+
async setIfAbsent(key, value, ttlMs) {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
return ((await q(`INSERT INTO ${kv} (key, value, expires_at) VALUES ($1, $2, $3)
|
|
131
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at
|
|
132
|
+
WHERE ${kv}.expires_at IS NOT NULL AND ${kv}.expires_at <= $4 RETURNING key`, [
|
|
133
|
+
key,
|
|
134
|
+
JSON.stringify(value),
|
|
135
|
+
ttlMs ? now + ttlMs : null,
|
|
136
|
+
now
|
|
137
|
+
])).rowCount ?? 0) > 0;
|
|
138
|
+
},
|
|
139
|
+
async delete(key) {
|
|
140
|
+
await q(`DELETE FROM ${kv} WHERE key = $1`, [key]);
|
|
141
|
+
},
|
|
142
|
+
async entries(prefix) {
|
|
143
|
+
const escaped = prefix.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
144
|
+
return (await q(`SELECT key, value FROM ${kv} WHERE key LIKE $1 AND (expires_at IS NULL OR expires_at > $2)`, [`${escaped}%`, Date.now()])).rows.map((r) => [r.key, JSON.parse(r.value)]);
|
|
145
|
+
},
|
|
146
|
+
async close() {
|
|
147
|
+
if (cleanup) clearInterval(cleanup);
|
|
148
|
+
if (listener) {
|
|
149
|
+
await listener.query(`UNLISTEN ${PG_CHANNEL}`).catch(() => {});
|
|
150
|
+
listener.release();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const UNLOCK = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
|
156
|
+
const EXTEND = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
|
|
157
|
+
/**
|
|
158
|
+
* Cluster adapter backed by Redis (ioredis API). Pass two connections: one for
|
|
159
|
+
* commands and one dedicated to subscriptions.
|
|
160
|
+
*/
|
|
161
|
+
function redisCluster(opts) {
|
|
162
|
+
const { client, subscriber } = opts;
|
|
163
|
+
const prefix = opts.prefix ?? "matrix:";
|
|
164
|
+
const channel = `${prefix}cluster`;
|
|
165
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
166
|
+
const queue = localQueue();
|
|
167
|
+
const ready = subscriber.subscribe(channel);
|
|
168
|
+
subscriber.on("message", (ch, raw) => {
|
|
169
|
+
if (ch !== channel) return;
|
|
170
|
+
try {
|
|
171
|
+
const { channel: name, message } = JSON.parse(raw);
|
|
172
|
+
for (const h of handlers.get(name) ?? []) h(message);
|
|
173
|
+
} catch {}
|
|
174
|
+
});
|
|
175
|
+
const k = (key) => `${prefix}${key}`;
|
|
176
|
+
return {
|
|
177
|
+
async nextId(name) {
|
|
178
|
+
return Number(await client.incr(k(`seq:${name}`)));
|
|
179
|
+
},
|
|
180
|
+
async currentId(name) {
|
|
181
|
+
return Number(await client.get(k(`seq:${name}`)) ?? 0);
|
|
182
|
+
},
|
|
183
|
+
async advance(name, value) {
|
|
184
|
+
await client.eval(`local v = tonumber(redis.call("get", KEYS[1]) or "0") if v < tonumber(ARGV[1]) then redis.call("set", KEYS[1], ARGV[1]) end return 1`, 1, k(`seq:${name}`), value);
|
|
185
|
+
},
|
|
186
|
+
lock(key, fn) {
|
|
187
|
+
return queue(key, async () => {
|
|
188
|
+
const lockKey = k(`lock:${key}`);
|
|
189
|
+
const token = randomUUID();
|
|
190
|
+
const ttl = 3e4;
|
|
191
|
+
for (let delay = 5;; delay = Math.min(delay * 2, 200)) {
|
|
192
|
+
if (await client.set(lockKey, token, "PX", ttl, "NX") === "OK") break;
|
|
193
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
194
|
+
}
|
|
195
|
+
const renew = setInterval(() => void client.eval(EXTEND, 1, lockKey, token, ttl).catch(() => {}), ttl / 3);
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
} finally {
|
|
199
|
+
clearInterval(renew);
|
|
200
|
+
await client.eval(UNLOCK, 1, lockKey, token).catch(() => {});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
async publish(name, message) {
|
|
205
|
+
await ready;
|
|
206
|
+
await client.publish(channel, JSON.stringify({
|
|
207
|
+
channel: name,
|
|
208
|
+
message
|
|
209
|
+
}));
|
|
210
|
+
},
|
|
211
|
+
subscribe(name, handler) {
|
|
212
|
+
let set = handlers.get(name);
|
|
213
|
+
if (!set) handlers.set(name, set = /* @__PURE__ */ new Set());
|
|
214
|
+
set.add(handler);
|
|
215
|
+
},
|
|
216
|
+
async get(key) {
|
|
217
|
+
const v = await client.get(k(`kv:${key}`));
|
|
218
|
+
return v === null ? null : JSON.parse(v);
|
|
219
|
+
},
|
|
220
|
+
async set(key, value, ttlMs) {
|
|
221
|
+
if (ttlMs) await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs);
|
|
222
|
+
else await client.set(k(`kv:${key}`), JSON.stringify(value));
|
|
223
|
+
},
|
|
224
|
+
async setIfAbsent(key, value, ttlMs) {
|
|
225
|
+
return (ttlMs ? await client.set(k(`kv:${key}`), JSON.stringify(value), "PX", ttlMs, "NX") : await client.set(k(`kv:${key}`), JSON.stringify(value), "NX")) === "OK";
|
|
226
|
+
},
|
|
227
|
+
async delete(key) {
|
|
228
|
+
await client.del(k(`kv:${key}`));
|
|
229
|
+
},
|
|
230
|
+
async entries(keyPrefix) {
|
|
231
|
+
const out = [];
|
|
232
|
+
let cursor = "0";
|
|
233
|
+
const pattern = `${k(`kv:${keyPrefix}`).replace(/[*?[\]]/g, (c) => `\\${c}`)}*`;
|
|
234
|
+
do {
|
|
235
|
+
const [next, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
|
|
236
|
+
cursor = next;
|
|
237
|
+
if (keys.length) {
|
|
238
|
+
const values = await client.mget(...keys);
|
|
239
|
+
keys.forEach((key, i) => {
|
|
240
|
+
if (values[i] !== null) out.push([key.slice(k("kv:").length), JSON.parse(values[i])]);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
} while (cursor !== "0");
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
//#endregion
|
|
249
|
+
export { localCluster, postgresCluster, redisCluster };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/adapters/media.d.ts
|
|
2
|
+
export interface MediaMetadata {
|
|
3
|
+
contentType: string | null;
|
|
4
|
+
filename: string | null;
|
|
5
|
+
size: number;
|
|
6
|
+
}
|
|
7
|
+
/** Stores the bytes of uploaded media. Metadata is kept in the database. */
|
|
8
|
+
export interface MediaAdapter {
|
|
9
|
+
put(mediaId: string, data: Uint8Array, meta: MediaMetadata): Promise<void>;
|
|
10
|
+
get(mediaId: string): Promise<Uint8Array | null>;
|
|
11
|
+
delete?(mediaId: string): Promise<void>;
|
|
12
|
+
/** Maximum upload size in bytes. Default: 50 MiB. */
|
|
13
|
+
maxUploadSize?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ThumbnailRequest {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
method: "crop" | "scale";
|
|
19
|
+
animated: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Produces a thumbnail. Return null to fall back to serving the original file
|
|
23
|
+
* (e.g. for unsupported formats).
|
|
24
|
+
*/
|
|
25
|
+
export type Thumbnailer = (data: Uint8Array, contentType: string, request: ThumbnailRequest) => Promise<{
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
contentType: string;
|
|
28
|
+
} | null>;
|
|
29
|
+
/** Minimal structural type of the `sharp` image library. */
|
|
30
|
+
type SharpLike = (input: Uint8Array, options?: {
|
|
31
|
+
animated?: boolean;
|
|
32
|
+
}) => {
|
|
33
|
+
resize(opts: {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
fit: "cover" | "inside";
|
|
37
|
+
withoutEnlargement?: boolean;
|
|
38
|
+
}): {
|
|
39
|
+
toFormat(format: "webp" | "png" | "jpeg"): {
|
|
40
|
+
toBuffer(): Promise<Uint8Array>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/** Thumbnailer backed by `sharp`: `thumbnailer: sharpThumbnailer(sharp)`. */
|
|
45
|
+
export declare function sharpThumbnailer(sharp: SharpLike): Thumbnailer;
|
|
46
|
+
export declare function memoryMedia(): MediaAdapter;
|
|
47
|
+
/** Stores media as files inside `directory`. */
|
|
48
|
+
export declare function filesystemMedia(directory: string): MediaAdapter;
|
|
49
|
+
//#endregion
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
//#region src/adapters/media.ts
|
|
4
|
+
/** Thumbnailer backed by `sharp`: `thumbnailer: sharpThumbnailer(sharp)`. */
|
|
5
|
+
function sharpThumbnailer(sharp) {
|
|
6
|
+
return async (data, contentType, req) => {
|
|
7
|
+
if (!/^image\/(png|jpeg|gif|webp|avif|tiff)$/.test(contentType)) return null;
|
|
8
|
+
const animated = req.animated && (contentType === "image/gif" || contentType === "image/webp");
|
|
9
|
+
const format = animated ? "webp" : contentType === "image/jpeg" ? "jpeg" : "png";
|
|
10
|
+
return {
|
|
11
|
+
data: await sharp(data, { animated }).resize({
|
|
12
|
+
width: req.width,
|
|
13
|
+
height: req.height,
|
|
14
|
+
fit: req.method === "crop" ? "cover" : "inside",
|
|
15
|
+
withoutEnlargement: true
|
|
16
|
+
}).toFormat(format).toBuffer(),
|
|
17
|
+
contentType: `image/${format}`
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function memoryMedia() {
|
|
22
|
+
const store = /* @__PURE__ */ new Map();
|
|
23
|
+
return {
|
|
24
|
+
async put(id, data) {
|
|
25
|
+
store.set(id, data);
|
|
26
|
+
},
|
|
27
|
+
async get(id) {
|
|
28
|
+
return store.get(id) ?? null;
|
|
29
|
+
},
|
|
30
|
+
async delete(id) {
|
|
31
|
+
store.delete(id);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Stores media as files inside `directory`. */
|
|
36
|
+
function filesystemMedia(directory) {
|
|
37
|
+
const root = resolve(directory);
|
|
38
|
+
const path = (id) => {
|
|
39
|
+
const file = resolve(root, id.replace(/[^A-Za-z0-9_-]/g, "_"));
|
|
40
|
+
if (dirname(file) !== root) throw new Error("Invalid media ID");
|
|
41
|
+
return file;
|
|
42
|
+
};
|
|
43
|
+
let ready = null;
|
|
44
|
+
return {
|
|
45
|
+
async put(id, data) {
|
|
46
|
+
ready ??= mkdir(directory, { recursive: true });
|
|
47
|
+
await ready;
|
|
48
|
+
await writeFile(path(id), data);
|
|
49
|
+
},
|
|
50
|
+
async get(id) {
|
|
51
|
+
try {
|
|
52
|
+
return await readFile(path(id));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async delete(id) {
|
|
58
|
+
await rm(path(id), { force: true });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { filesystemMedia, memoryMedia, sharpThumbnailer };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MediaAdapter } from "./media.js";
|
|
2
|
+
//#region src/adapters/s3.d.ts
|
|
3
|
+
export interface S3MediaOptions {
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** e.g. "us-east-1"; "auto" for Cloudflare R2. */
|
|
6
|
+
region: string;
|
|
7
|
+
accessKeyId: string;
|
|
8
|
+
secretAccessKey: string;
|
|
9
|
+
/** Temporary credentials (STS) also carry a session token. */
|
|
10
|
+
sessionToken?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Service endpoint, for S3-compatible stores (MinIO, R2, Garage, Ceph...).
|
|
13
|
+
* Default: AWS, `https://s3.<region>.amazonaws.com`.
|
|
14
|
+
*/
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Address the bucket as `<endpoint>/<bucket>/<key>` instead of `<bucket>.<endpoint>/<key>`.
|
|
18
|
+
* Default: true with a custom endpoint, false on AWS.
|
|
19
|
+
*/
|
|
20
|
+
forcePathStyle?: boolean;
|
|
21
|
+
/** Key prefix for all objects, e.g. "matrix/media/". */
|
|
22
|
+
prefix?: string;
|
|
23
|
+
/** Maximum upload size in bytes. Default: 50 MiB. */
|
|
24
|
+
maxUploadSize?: number;
|
|
25
|
+
/** Per-request timeout in ms. Default: 30 s. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Media stored in an S3 bucket (AWS S3 or any S3-compatible store). Requests are signed with
|
|
30
|
+
* AWS Signature Version 4; no SDK is needed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function s3Media(options: S3MediaOptions): MediaAdapter;
|
|
33
|
+
//#endregion
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
//#region src/adapters/s3.ts
|
|
3
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
4
|
+
const hmac = (key, data) => createHmac("sha256", key).update(data).digest();
|
|
5
|
+
const encodePath = (path) => path.split("/").map((s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join("/");
|
|
6
|
+
/**
|
|
7
|
+
* Media stored in an S3 bucket (AWS S3 or any S3-compatible store). Requests are signed with
|
|
8
|
+
* AWS Signature Version 4; no SDK is needed.
|
|
9
|
+
*/
|
|
10
|
+
function s3Media(options) {
|
|
11
|
+
const endpoint = new URL(options.endpoint ?? `https://s3.${options.region}.amazonaws.com`);
|
|
12
|
+
const pathStyle = options.forcePathStyle ?? options.endpoint !== void 0;
|
|
13
|
+
const prefix = options.prefix ?? "";
|
|
14
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
15
|
+
const objectUrl = (mediaId) => {
|
|
16
|
+
const key = prefix + mediaId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
17
|
+
const url = new URL(endpoint);
|
|
18
|
+
const base = endpoint.pathname.replace(/\/$/, "");
|
|
19
|
+
if (pathStyle) url.pathname = `${base}/${encodePath(options.bucket)}/${encodePath(key)}`;
|
|
20
|
+
else {
|
|
21
|
+
url.hostname = `${options.bucket}.${endpoint.hostname}`;
|
|
22
|
+
url.pathname = `${base}/${encodePath(key)}`;
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
};
|
|
26
|
+
const send = async (method, url, body, extra = {}) => {
|
|
27
|
+
const amzDate = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
28
|
+
const day = amzDate.slice(0, 8);
|
|
29
|
+
const payloadHash = sha256(body ?? "");
|
|
30
|
+
const headers = {
|
|
31
|
+
host: url.host,
|
|
32
|
+
"x-amz-content-sha256": payloadHash,
|
|
33
|
+
"x-amz-date": amzDate,
|
|
34
|
+
...options.sessionToken ? { "x-amz-security-token": options.sessionToken } : {},
|
|
35
|
+
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k.toLowerCase(), v]))
|
|
36
|
+
};
|
|
37
|
+
const names = Object.keys(headers).sort();
|
|
38
|
+
const canonicalRequest = [
|
|
39
|
+
method,
|
|
40
|
+
url.pathname,
|
|
41
|
+
"",
|
|
42
|
+
names.map((n) => `${n}:${headers[n].trim()}\n`).join(""),
|
|
43
|
+
names.join(";"),
|
|
44
|
+
payloadHash
|
|
45
|
+
].join("\n");
|
|
46
|
+
const scope = `${day}/${options.region}/s3/aws4_request`;
|
|
47
|
+
const stringToSign = [
|
|
48
|
+
"AWS4-HMAC-SHA256",
|
|
49
|
+
amzDate,
|
|
50
|
+
scope,
|
|
51
|
+
sha256(canonicalRequest)
|
|
52
|
+
].join("\n");
|
|
53
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${options.secretAccessKey}`, day), options.region), "s3"), "aws4_request");
|
|
54
|
+
const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
|
|
55
|
+
const { host: _host, ...sent } = headers;
|
|
56
|
+
return fetch(url, {
|
|
57
|
+
method,
|
|
58
|
+
headers: {
|
|
59
|
+
...sent,
|
|
60
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${options.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}`
|
|
61
|
+
},
|
|
62
|
+
body,
|
|
63
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const fail = async (what, res) => {
|
|
67
|
+
const text = await res.text().catch(() => "");
|
|
68
|
+
const code = /<Code>([^<]*)<\/Code>/.exec(text)?.[1];
|
|
69
|
+
throw new Error(`S3 ${what} failed: ${res.status}${code ? ` ${code}` : ""}`);
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
maxUploadSize: options.maxUploadSize,
|
|
73
|
+
async put(mediaId, data, meta) {
|
|
74
|
+
const res = await send("PUT", objectUrl(mediaId), data, meta.contentType ? { "content-type": meta.contentType } : {});
|
|
75
|
+
if (!res.ok) await fail("upload", res);
|
|
76
|
+
await res.arrayBuffer();
|
|
77
|
+
},
|
|
78
|
+
async get(mediaId) {
|
|
79
|
+
const res = await send("GET", objectUrl(mediaId));
|
|
80
|
+
if (res.status === 404) {
|
|
81
|
+
await res.arrayBuffer();
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!res.ok) await fail("download", res);
|
|
85
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
86
|
+
},
|
|
87
|
+
async delete(mediaId) {
|
|
88
|
+
const res = await send("DELETE", objectUrl(mediaId));
|
|
89
|
+
if (!res.ok && res.status !== 404) await fail("delete", res);
|
|
90
|
+
await res.arrayBuffer();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { s3Media };
|