317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
import type { Logger } from "pino";
|
|
import { isMatrixEvent, parseMxc, type MatrixEvent, type RoomEncryption } from "./events.js";
|
|
import { PermanentArchiveError, parseContentLength } from "./inspector.js";
|
|
|
|
export class MatrixHttpError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly errcode: string | null,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export interface JoinedRoomSync {
|
|
timeline: { events: MatrixEvent[]; limited: boolean; prevBatch: string | null };
|
|
stateEvents: MatrixEvent[];
|
|
}
|
|
|
|
export interface SyncResult {
|
|
nextBatch: string;
|
|
joined: Map<string, JoinedRoomSync>;
|
|
invited: string[];
|
|
}
|
|
|
|
export interface MediaDownload {
|
|
body: ReadableStream<Uint8Array>;
|
|
contentLength: number | null;
|
|
}
|
|
|
|
/** The narrow Matrix surface the processor needs; faked in tests. */
|
|
export interface MatrixPort {
|
|
readonly userId: string;
|
|
sync(since: string | null, signal: AbortSignal): Promise<SyncResult>;
|
|
/** Older events between `from` (a prev_batch) back to `to` (the previous sync token), oldest first. */
|
|
backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise<MatrixEvent[]>;
|
|
roomEncryption(roomId: string, signal: AbortSignal): Promise<RoomEncryption>;
|
|
joinRoom(roomId: string, signal: AbortSignal): Promise<void>;
|
|
downloadMedia(mxcUri: string, signal: AbortSignal): Promise<MediaDownload>;
|
|
sendNotice(roomId: string, txnId: string, content: Record<string, unknown>, signal: AbortSignal): Promise<void>;
|
|
/** Phase-B export gate: current `m.room.power_levels` for a mapped room. */
|
|
roomPowerLevels(roomId: string, signal: AbortSignal): Promise<Record<string, unknown>>;
|
|
/** Every room the account has joined (encryption lint input). */
|
|
joinedRooms(signal: AbortSignal): Promise<string[]>;
|
|
}
|
|
|
|
const SYNC_TIMEOUT_MS = 30_000;
|
|
const MAX_REDIRECTS = 3;
|
|
const BACKFILL_PAGES = 20;
|
|
const TIMELINE_TYPES = ["m.room.message", "m.room.redaction", "m.room.encrypted", "m.room.encryption"];
|
|
|
|
/** Client-side filter: mapped rooms only, minimal timeline types, no presence/ephemeral noise. */
|
|
export function syncFilter(roomIds: readonly string[]): Record<string, unknown> {
|
|
return {
|
|
presence: { not_types: ["*"] },
|
|
account_data: { not_types: ["*"] },
|
|
room: {
|
|
rooms: [...roomIds],
|
|
account_data: { not_types: ["*"] },
|
|
ephemeral: { not_types: ["*"] },
|
|
state: { types: ["m.room.encryption"], lazy_load_members: true },
|
|
timeline: { limit: 50, types: TIMELINE_TYPES },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Authenticated media requires spec v1.11+ (or the MSC3916 stable flag on older advertisers). */
|
|
export function supportsAuthenticatedMedia(versions: unknown): boolean {
|
|
if (typeof versions !== "object" || versions === null) return false;
|
|
const record = versions as { versions?: unknown; unstable_features?: unknown };
|
|
const list = Array.isArray(record.versions) ? record.versions.filter((v): v is string => typeof v === "string") : [];
|
|
const modern = list.some((v) => {
|
|
const match = /^v1\.(\d+)$/.exec(v);
|
|
return match !== null && Number(match[1]) >= 11;
|
|
});
|
|
const features = record.unstable_features;
|
|
const flagged = typeof features === "object" && features !== null
|
|
&& (features as Record<string, unknown>)["org.matrix.msc3916.stable"] === true;
|
|
return modern || flagged;
|
|
}
|
|
|
|
/**
|
|
* Redirect policy for media downloads. Bearer auth is only ever sent to the
|
|
* homeserver origin; other hosts must be explicitly allowlisted (e.g. the R2
|
|
* endpoint) and are fetched without credentials. Downgrades to http are refused.
|
|
*/
|
|
export function resolveRedirect(
|
|
current: URL,
|
|
location: string,
|
|
homeserver: URL,
|
|
allowedHosts: readonly string[],
|
|
): { url: URL; sendAuth: boolean } {
|
|
const url = new URL(location, current);
|
|
if (url.origin === homeserver.origin) return { url, sendAuth: true };
|
|
if (url.protocol !== "https:") throw new PermanentArchiveError(`refusing non-https media redirect to ${url.host}`);
|
|
if (!allowedHosts.includes(url.hostname.toLowerCase())) {
|
|
throw new PermanentArchiveError(`media redirect to unapproved host ${url.hostname}`);
|
|
}
|
|
return { url, sendAuth: false };
|
|
}
|
|
|
|
function events(value: unknown): MatrixEvent[] {
|
|
return Array.isArray(value) ? value.filter(isMatrixEvent) : [];
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
export function parseSync(body: unknown): SyncResult {
|
|
const root = record(body);
|
|
if (typeof root.next_batch !== "string") throw new Error("sync response missing next_batch");
|
|
const rooms = record(root.rooms);
|
|
const joined = new Map<string, JoinedRoomSync>();
|
|
for (const [roomId, raw] of Object.entries(record(rooms.join))) {
|
|
const room = record(raw);
|
|
const timeline = record(room.timeline);
|
|
joined.set(roomId, {
|
|
timeline: {
|
|
events: events(timeline.events),
|
|
limited: timeline.limited === true,
|
|
prevBatch: typeof timeline.prev_batch === "string" ? timeline.prev_batch : null,
|
|
},
|
|
stateEvents: events(record(room.state).events),
|
|
});
|
|
}
|
|
return { nextBatch: root.next_batch, joined, invited: Object.keys(record(rooms.invite)) };
|
|
}
|
|
|
|
export interface MatrixClientOptions {
|
|
homeserver: URL;
|
|
user: string;
|
|
password: string;
|
|
deviceId: string;
|
|
mediaRedirectHosts: string[];
|
|
roomIds: string[];
|
|
logger: Logger;
|
|
}
|
|
|
|
/** Plain client-server sync-loop client (not an appservice). No crypto: encrypted content is never touched. */
|
|
export class MatrixClient implements MatrixPort {
|
|
private accessToken: string | null = null;
|
|
private resolvedUserId = "";
|
|
private readonly filter: string;
|
|
|
|
constructor(private readonly opts: MatrixClientOptions) {
|
|
this.filter = JSON.stringify(syncFilter(opts.roomIds));
|
|
}
|
|
|
|
get userId(): string {
|
|
return this.resolvedUserId;
|
|
}
|
|
|
|
async login(signal: AbortSignal): Promise<void> {
|
|
const versions = await this.request("GET", "/_matrix/client/versions", { signal, auth: false });
|
|
if (!supportsAuthenticatedMedia(versions)) {
|
|
throw new Error("homeserver does not support authenticated media (v1.11); refusing to start");
|
|
}
|
|
const body = record(await this.request("POST", "/_matrix/client/v3/login", {
|
|
signal,
|
|
auth: false,
|
|
json: {
|
|
type: "m.login.password",
|
|
identifier: { type: "m.id.user", user: this.opts.user },
|
|
password: this.opts.password,
|
|
device_id: this.opts.deviceId,
|
|
initial_device_display_name: "Hub archive bot",
|
|
},
|
|
}));
|
|
if (typeof body.access_token !== "string" || typeof body.user_id !== "string") {
|
|
throw new Error("login response missing access_token/user_id");
|
|
}
|
|
this.accessToken = body.access_token;
|
|
this.resolvedUserId = body.user_id;
|
|
}
|
|
|
|
async sync(since: string | null, signal: AbortSignal): Promise<SyncResult> {
|
|
const query = new URLSearchParams({ filter: this.filter, timeout: String(since === null ? 0 : SYNC_TIMEOUT_MS) });
|
|
if (since !== null) query.set("since", since);
|
|
const timeout = AbortSignal.any([signal, AbortSignal.timeout(SYNC_TIMEOUT_MS + 30_000)]);
|
|
return parseSync(await this.request("GET", `/_matrix/client/v3/sync?${query.toString()}`, { signal: timeout }));
|
|
}
|
|
|
|
async backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise<MatrixEvent[]> {
|
|
const collected: MatrixEvent[] = [];
|
|
let cursor: string | null = from;
|
|
for (let page = 0; page < BACKFILL_PAGES && cursor !== null; page += 1) {
|
|
const query = new URLSearchParams({
|
|
from: cursor,
|
|
to,
|
|
dir: "b",
|
|
limit: "100",
|
|
filter: JSON.stringify({ types: TIMELINE_TYPES }),
|
|
});
|
|
const body = record(await this.request(
|
|
"GET",
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${query.toString()}`,
|
|
{ signal },
|
|
));
|
|
const chunk = events(body.chunk);
|
|
collected.push(...chunk);
|
|
cursor = chunk.length > 0 && typeof body.end === "string" ? body.end : null;
|
|
}
|
|
if (cursor !== null) this.opts.logger.warn({ roomId }, "matrix: backfill page budget exhausted; older gap events skipped");
|
|
return collected.reverse();
|
|
}
|
|
|
|
async roomEncryption(roomId: string, signal: AbortSignal): Promise<RoomEncryption> {
|
|
try {
|
|
const body = record(await this.request(
|
|
"GET",
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
|
|
{ signal },
|
|
));
|
|
// Any present encryption state (even an unknown algorithm) means encrypted.
|
|
return Object.keys(body).length > 0 ? "encrypted" : "unknown";
|
|
} catch (err) {
|
|
if (err instanceof MatrixHttpError && err.status === 404 && err.errcode === "M_NOT_FOUND") return "unencrypted";
|
|
if (err instanceof MatrixHttpError && (err.status === 403 || err.status === 404)) return "unknown";
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async joinRoom(roomId: string, signal: AbortSignal): Promise<void> {
|
|
await this.request("POST", `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, { signal, json: {} });
|
|
}
|
|
|
|
async sendNotice(roomId: string, txnId: string, content: Record<string, unknown>, signal: AbortSignal): Promise<void> {
|
|
await this.request(
|
|
"PUT",
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
|
{ signal, json: content },
|
|
);
|
|
}
|
|
|
|
async roomPowerLevels(roomId: string, signal: AbortSignal): Promise<Record<string, unknown>> {
|
|
const body = record(await this.request(
|
|
"GET",
|
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.power_levels/`,
|
|
{ signal },
|
|
));
|
|
return body;
|
|
}
|
|
|
|
async joinedRooms(signal: AbortSignal): Promise<string[]> {
|
|
const body = record(await this.request("GET", "/_matrix/client/v3/joined_rooms", { signal }));
|
|
const joined = body.joined_rooms;
|
|
return Array.isArray(joined) ? joined.filter((r): r is string => typeof r === "string") : [];
|
|
}
|
|
|
|
/** Authenticated media endpoint only; the URL is derived from a validated mxc URI, never user-supplied HTTP. */
|
|
async downloadMedia(mxcUri: string, signal: AbortSignal): Promise<MediaDownload> {
|
|
const { serverName, mediaId } = parseMxc(mxcUri);
|
|
let url = new URL(
|
|
`/_matrix/client/v1/media/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}?allow_redirect=true`,
|
|
this.opts.homeserver,
|
|
);
|
|
let sendAuth = true;
|
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
const headers: Record<string, string> = {};
|
|
if (sendAuth) headers.authorization = `Bearer ${this.token()}`;
|
|
const res = await fetch(url, { headers, redirect: "manual", signal });
|
|
if (res.status >= 300 && res.status < 400) {
|
|
const location = res.headers.get("location");
|
|
await res.body?.cancel();
|
|
if (location === null) throw new Error("media redirect without Location");
|
|
({ url, sendAuth } = resolveRedirect(url, location, this.opts.homeserver, this.opts.mediaRedirectHosts));
|
|
continue;
|
|
}
|
|
if (!res.ok || res.body === null) {
|
|
const error = await this.toError(res);
|
|
// Missing/forbidden/too-large media will not appear on retry.
|
|
if (res.status === 404 || res.status === 403 || res.status === 413) throw new PermanentArchiveError(error.message);
|
|
throw error;
|
|
}
|
|
return { body: res.body, contentLength: parseContentLength(res.headers.get("content-length")) };
|
|
}
|
|
throw new PermanentArchiveError("too many media redirects");
|
|
}
|
|
|
|
private token(): string {
|
|
if (this.accessToken === null) throw new Error("matrix client not logged in");
|
|
return this.accessToken;
|
|
}
|
|
|
|
private async request(
|
|
method: string,
|
|
path: string,
|
|
opts: { signal: AbortSignal; auth?: boolean; json?: unknown },
|
|
): Promise<unknown> {
|
|
const headers: Record<string, string> = { accept: "application/json" };
|
|
if (opts.auth !== false) headers.authorization = `Bearer ${this.token()}`;
|
|
if (opts.json !== undefined) headers["content-type"] = "application/json";
|
|
const res = await fetch(new URL(path, this.opts.homeserver), {
|
|
method,
|
|
headers,
|
|
body: opts.json === undefined ? undefined : JSON.stringify(opts.json),
|
|
redirect: "error",
|
|
signal: opts.signal,
|
|
});
|
|
if (!res.ok) throw await this.toError(res);
|
|
return res.json() as Promise<unknown>;
|
|
}
|
|
|
|
private async toError(res: Response): Promise<MatrixHttpError> {
|
|
let errcode: string | null = null;
|
|
let message = `matrix HTTP ${res.status}`;
|
|
try {
|
|
const body = record(await res.json());
|
|
if (typeof body.errcode === "string") errcode = body.errcode;
|
|
if (typeof body.error === "string") message = `${message}: ${body.error}`;
|
|
} catch {
|
|
// Non-JSON error body; status is enough.
|
|
}
|
|
return new MatrixHttpError(res.status, errcode, message);
|
|
}
|
|
}
|