atagen

malice

public
Star0
Watch0
20fe182f3204e4d4923607816147c3fa0ae4cccc
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/sw-media.ts

d8772d4eb408atagen2026-10-26 04:43 UTC1
import { statusForKind } from './lib/wire-status';
d8772d4eb408atagen2026-10-26 04:43 UTC2
import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
d8772d4eb408atagen2026-10-26 04:43 UTC3
import { attachmentContentType } from './lib/media-sniff';
d8772d4eb408atagen2026-10-26 04:43 UTC4
import { createAvatarLoader } from './lib/avatar-cache';
d8772d4eb408atagen2026-10-26 04:43 UTC5
d8772d4eb408atagen2026-10-26 04:43 UTC6
export const AVATAR_CACHE = 'malice-avatars-v3';
d8772d4eb408atagen2026-10-26 04:43 UTC7
d8772d4eb408atagen2026-10-26 04:43 UTC8
interface MediaReply {
d8772d4eb408atagen2026-10-26 04:43 UTC9
  bytes?: Uint8Array<ArrayBuffer>;
d8772d4eb408atagen2026-10-26 04:43 UTC10
  content_type?: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC11
  error?: { kind: string; message: string };
d8772d4eb408atagen2026-10-26 04:43 UTC12
}
d8772d4eb408atagen2026-10-26 04:43 UTC13
d8772d4eb408atagen2026-10-26 04:43 UTC14
interface AttachmentEncryptionWire {
d8772d4eb408atagen2026-10-26 04:43 UTC15
  key: string;
d8772d4eb408atagen2026-10-26 04:43 UTC16
  iv: string;
d8772d4eb408atagen2026-10-26 04:43 UTC17
  sha256: string;
d8772d4eb408atagen2026-10-26 04:43 UTC18
}
d8772d4eb408atagen2026-10-26 04:43 UTC19
d8772d4eb408atagen2026-10-26 04:43 UTC20
interface MediaFileWire {
d8772d4eb408atagen2026-10-26 04:43 UTC21
  url: string;
d8772d4eb408atagen2026-10-26 04:43 UTC22
  mimetype: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC23
  declared_size: number | null;
d8772d4eb408atagen2026-10-26 04:43 UTC24
  encryption: AttachmentEncryptionWire | null;
d8772d4eb408atagen2026-10-26 04:43 UTC25
}
d8772d4eb408atagen2026-10-26 04:43 UTC26
d8772d4eb408atagen2026-10-26 04:43 UTC27
type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';
d8772d4eb408atagen2026-10-26 04:43 UTC28
d8772d4eb408atagen2026-10-26 04:43 UTC29
interface LocateReply {
d8772d4eb408atagen2026-10-26 04:43 UTC30
  location?: MediaLocationWire;
d8772d4eb408atagen2026-10-26 04:43 UTC31
  token?: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC32
  error?: { kind: string; message: string };
d8772d4eb408atagen2026-10-26 04:43 UTC33
}
d8772d4eb408atagen2026-10-26 04:43 UTC34
d8772d4eb408atagen2026-10-26 04:43 UTC35
const MEDIA_TIMEOUT_MS = 30000;
d8772d4eb408atagen2026-10-26 04:43 UTC36
const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();
d8772d4eb408atagen2026-10-26 04:43 UTC37
let nextMediaRequestId = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC38
let mediaChannel: BroadcastChannel | undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC39
d8772d4eb408atagen2026-10-26 04:43 UTC40
function channel(): BroadcastChannel {
d8772d4eb408atagen2026-10-26 04:43 UTC41
  if (!mediaChannel) {
d8772d4eb408atagen2026-10-26 04:43 UTC42
    mediaChannel = new BroadcastChannel('malice-media');
d8772d4eb408atagen2026-10-26 04:43 UTC43
    mediaChannel.onmessage = (ev: MessageEvent) => {
d8772d4eb408atagen2026-10-26 04:43 UTC44
      const msg = ev.data as MediaReply & LocateReply & { id: string };
d8772d4eb408atagen2026-10-26 04:43 UTC45
      const resolve = pendingMedia.get(msg.id);
d8772d4eb408atagen2026-10-26 04:43 UTC46
      if (!resolve) return;
d8772d4eb408atagen2026-10-26 04:43 UTC47
      pendingMedia.delete(msg.id);
d8772d4eb408atagen2026-10-26 04:43 UTC48
      resolve(msg);
d8772d4eb408atagen2026-10-26 04:43 UTC49
    };
d8772d4eb408atagen2026-10-26 04:43 UTC50
  }
d8772d4eb408atagen2026-10-26 04:43 UTC51
  return mediaChannel;
d8772d4eb408atagen2026-10-26 04:43 UTC52
}
d8772d4eb408atagen2026-10-26 04:43 UTC53
d8772d4eb408atagen2026-10-26 04:43 UTC54
function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {
d8772d4eb408atagen2026-10-26 04:43 UTC55
  const id = `${Date.now()}-${nextMediaRequestId++}`;
d8772d4eb408atagen2026-10-26 04:43 UTC56
  return new Promise((resolve) => {
d8772d4eb408atagen2026-10-26 04:43 UTC57
    const timer = setTimeout(() => {
d8772d4eb408atagen2026-10-26 04:43 UTC58
      pendingMedia.delete(id);
d8772d4eb408atagen2026-10-26 04:43 UTC59
      resolve(null);
d8772d4eb408atagen2026-10-26 04:43 UTC60
    }, MEDIA_TIMEOUT_MS);
d8772d4eb408atagen2026-10-26 04:43 UTC61
    pendingMedia.set(id, (reply) => {
d8772d4eb408atagen2026-10-26 04:43 UTC62
      clearTimeout(timer);
d8772d4eb408atagen2026-10-26 04:43 UTC63
      resolve(reply);
d8772d4eb408atagen2026-10-26 04:43 UTC64
    });
d8772d4eb408atagen2026-10-26 04:43 UTC65
    channel().postMessage({ id, kind, ...fields });
d8772d4eb408atagen2026-10-26 04:43 UTC66
  });
d8772d4eb408atagen2026-10-26 04:43 UTC67
}
d8772d4eb408atagen2026-10-26 04:43 UTC68
d8772d4eb408atagen2026-10-26 04:43 UTC69
interface LocateEntry {
d8772d4eb408atagen2026-10-26 04:43 UTC70
  location: MediaLocationWire;
d8772d4eb408atagen2026-10-26 04:43 UTC71
  token: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC72
  expiresAt: number;
d8772d4eb408atagen2026-10-26 04:43 UTC73
}
d8772d4eb408atagen2026-10-26 04:43 UTC74
d8772d4eb408atagen2026-10-26 04:43 UTC75
const LOCATE_TTL_MS = 5 * 60 * 1000;
d8772d4eb408atagen2026-10-26 04:43 UTC76
const locateCache = new Map<string, LocateEntry>();
d8772d4eb408atagen2026-10-26 04:43 UTC77
const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();
d8772d4eb408atagen2026-10-26 04:43 UTC78
d8772d4eb408atagen2026-10-26 04:43 UTC79
function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {
d8772d4eb408atagen2026-10-26 04:43 UTC80
  const id = `${Date.now()}-${nextMediaRequestId++}`;
d8772d4eb408atagen2026-10-26 04:43 UTC81
  return new Promise((resolve) => {
d8772d4eb408atagen2026-10-26 04:43 UTC82
    const timer = setTimeout(() => {
d8772d4eb408atagen2026-10-26 04:43 UTC83
      pendingMedia.delete(id);
d8772d4eb408atagen2026-10-26 04:43 UTC84
      resolve(null);
d8772d4eb408atagen2026-10-26 04:43 UTC85
    }, MEDIA_TIMEOUT_MS);
d8772d4eb408atagen2026-10-26 04:43 UTC86
    pendingMedia.set(id, (reply) => {
d8772d4eb408atagen2026-10-26 04:43 UTC87
      clearTimeout(timer);
d8772d4eb408atagen2026-10-26 04:43 UTC88
      resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });
d8772d4eb408atagen2026-10-26 04:43 UTC89
    });
d8772d4eb408atagen2026-10-26 04:43 UTC90
    channel().postMessage({ id, kind: 'locate', media_id: mediaId, size });
d8772d4eb408atagen2026-10-26 04:43 UTC91
  });
d8772d4eb408atagen2026-10-26 04:43 UTC92
}
d8772d4eb408atagen2026-10-26 04:43 UTC93
d8772d4eb408atagen2026-10-26 04:43 UTC94
/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */
d8772d4eb408atagen2026-10-26 04:43 UTC95
async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {
d8772d4eb408atagen2026-10-26 04:43 UTC96
  if (!force) {
d8772d4eb408atagen2026-10-26 04:43 UTC97
    const cached = locateCache.get(mediaId);
d8772d4eb408atagen2026-10-26 04:43 UTC98
    if (cached && cached.expiresAt > Date.now()) return cached;
d8772d4eb408atagen2026-10-26 04:43 UTC99
  }
d8772d4eb408atagen2026-10-26 04:43 UTC100
  const reply = await requestLocate(mediaId, size);
d8772d4eb408atagen2026-10-26 04:43 UTC101
  if (!reply) return null;
d8772d4eb408atagen2026-10-26 04:43 UTC102
  const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };
d8772d4eb408atagen2026-10-26 04:43 UTC103
  locateCache.set(mediaId, entry);
d8772d4eb408atagen2026-10-26 04:43 UTC104
  return entry;
d8772d4eb408atagen2026-10-26 04:43 UTC105
}
d8772d4eb408atagen2026-10-26 04:43 UTC106
d8772d4eb408atagen2026-10-26 04:43 UTC107
interface RangeSlice {
d8772d4eb408atagen2026-10-26 04:43 UTC108
  body: Uint8Array<ArrayBuffer>;
d8772d4eb408atagen2026-10-26 04:43 UTC109
  status: number;
d8772d4eb408atagen2026-10-26 04:43 UTC110
  contentRange?: string;
d8772d4eb408atagen2026-10-26 04:43 UTC111
}
d8772d4eb408atagen2026-10-26 04:43 UTC112
d8772d4eb408atagen2026-10-26 04:43 UTC113
/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */
d8772d4eb408atagen2026-10-26 04:43 UTC114
function sliceForRange(bytes: Uint8Array<ArrayBuffer>, rangeHeader: string | null): RangeSlice {
d8772d4eb408atagen2026-10-26 04:43 UTC115
  const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
d8772d4eb408atagen2026-10-26 04:43 UTC116
  if (!match) return { body: bytes, status: 200 };
d8772d4eb408atagen2026-10-26 04:43 UTC117
  const total = bytes.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC118
  let start = match[1] ? parseInt(match[1], 10) : undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC119
  let end = match[2] ? parseInt(match[2], 10) : undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC120
  if (start === undefined && end !== undefined) {
d8772d4eb408atagen2026-10-26 04:43 UTC121
    start = Math.max(total - end, 0);
d8772d4eb408atagen2026-10-26 04:43 UTC122
    end = total - 1;
d8772d4eb408atagen2026-10-26 04:43 UTC123
  } else if (end === undefined) {
d8772d4eb408atagen2026-10-26 04:43 UTC124
    end = total - 1;
d8772d4eb408atagen2026-10-26 04:43 UTC125
  }
d8772d4eb408atagen2026-10-26 04:43 UTC126
  if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };
d8772d4eb408atagen2026-10-26 04:43 UTC127
  end = Math.min(end, total - 1);
d8772d4eb408atagen2026-10-26 04:43 UTC128
  return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };
d8772d4eb408atagen2026-10-26 04:43 UTC129
}
d8772d4eb408atagen2026-10-26 04:43 UTC130
d8772d4eb408atagen2026-10-26 04:43 UTC131
function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {
d8772d4eb408atagen2026-10-26 04:43 UTC132
  if (!reply) return new Response(null, { status: 504 });
d8772d4eb408atagen2026-10-26 04:43 UTC133
  if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });
d8772d4eb408atagen2026-10-26 04:43 UTC134
  const bytes = reply.bytes ?? new Uint8Array();
d8772d4eb408atagen2026-10-26 04:43 UTC135
  const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
d8772d4eb408atagen2026-10-26 04:43 UTC136
  const headers = new Headers({
d8772d4eb408atagen2026-10-26 04:43 UTC137
    'Content-Type': reply.content_type ?? 'application/octet-stream',
d8772d4eb408atagen2026-10-26 04:43 UTC138
    'Cache-Control': attachment ? 'private, max-age=3600' : 'private, max-age=31536000, immutable',
d8772d4eb408atagen2026-10-26 04:43 UTC139
    'X-Content-Type-Options': 'nosniff'
d8772d4eb408atagen2026-10-26 04:43 UTC140
  });
d8772d4eb408atagen2026-10-26 04:43 UTC141
  if (attachment) {
d8772d4eb408atagen2026-10-26 04:43 UTC142
    headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');
d8772d4eb408atagen2026-10-26 04:43 UTC143
    headers.set('Content-Security-Policy', 'sandbox');
d8772d4eb408atagen2026-10-26 04:43 UTC144
  }
d8772d4eb408atagen2026-10-26 04:43 UTC145
  if (contentRange) {
d8772d4eb408atagen2026-10-26 04:43 UTC146
    headers.set('Content-Range', contentRange);
d8772d4eb408atagen2026-10-26 04:43 UTC147
    headers.set('Accept-Ranges', 'bytes');
d8772d4eb408atagen2026-10-26 04:43 UTC148
  }
d8772d4eb408atagen2026-10-26 04:43 UTC149
  return new Response(body, { status, headers });
d8772d4eb408atagen2026-10-26 04:43 UTC150
}
d8772d4eb408atagen2026-10-26 04:43 UTC151
d8772d4eb408atagen2026-10-26 04:43 UTC152
interface ByteRange {
d8772d4eb408atagen2026-10-26 04:43 UTC153
  start: number;
d8772d4eb408atagen2026-10-26 04:43 UTC154
  end: number;
d8772d4eb408atagen2026-10-26 04:43 UTC155
}
d8772d4eb408atagen2026-10-26 04:43 UTC156
d8772d4eb408atagen2026-10-26 04:43 UTC157
/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */
d8772d4eb408atagen2026-10-26 04:43 UTC158
function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {
d8772d4eb408atagen2026-10-26 04:43 UTC159
  const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
d8772d4eb408atagen2026-10-26 04:43 UTC160
  if (!match) return null;
d8772d4eb408atagen2026-10-26 04:43 UTC161
  let start = match[1] ? parseInt(match[1], 10) : undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC162
  let end = match[2] ? parseInt(match[2], 10) : undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC163
  if (start === undefined && end !== undefined) {
d8772d4eb408atagen2026-10-26 04:43 UTC164
    start = Math.max(total - end, 0);
d8772d4eb408atagen2026-10-26 04:43 UTC165
    end = total - 1;
d8772d4eb408atagen2026-10-26 04:43 UTC166
  } else if (end === undefined) {
d8772d4eb408atagen2026-10-26 04:43 UTC167
    start = start ?? 0;
d8772d4eb408atagen2026-10-26 04:43 UTC168
    end = total - 1;
d8772d4eb408atagen2026-10-26 04:43 UTC169
  }
d8772d4eb408atagen2026-10-26 04:43 UTC170
  if (start === undefined || start > end || start >= total) return null;
d8772d4eb408atagen2026-10-26 04:43 UTC171
  return { start, end: Math.min(end, total - 1) };
d8772d4eb408atagen2026-10-26 04:43 UTC172
}
d8772d4eb408atagen2026-10-26 04:43 UTC173
d8772d4eb408atagen2026-10-26 04:43 UTC174
function attachmentHeaders(contentType: string | null): Headers {
d8772d4eb408atagen2026-10-26 04:43 UTC175
  return new Headers({
d8772d4eb408atagen2026-10-26 04:43 UTC176
    'Content-Type': contentType ?? 'application/octet-stream',
d8772d4eb408atagen2026-10-26 04:43 UTC177
    'Cache-Control': 'private, max-age=3600',
d8772d4eb408atagen2026-10-26 04:43 UTC178
    'X-Content-Type-Options': 'nosniff',
d8772d4eb408atagen2026-10-26 04:43 UTC179
    'Content-Disposition': contentType ? 'inline' : 'attachment',
d8772d4eb408atagen2026-10-26 04:43 UTC180
    'Content-Security-Policy': 'sandbox'
d8772d4eb408atagen2026-10-26 04:43 UTC181
  });
d8772d4eb408atagen2026-10-26 04:43 UTC182
}
d8772d4eb408atagen2026-10-26 04:43 UTC183
d8772d4eb408atagen2026-10-26 04:43 UTC184
/**
d8772d4eb408atagen2026-10-26 04:43 UTC185
 * The Authorization header carries the account's access token, so it's only worth the risk on the
d8772d4eb408atagen2026-10-26 04:43 UTC186
 * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named
d8772d4eb408atagen2026-10-26 04:43 UTC187
 * (never a redirect target, should a future caller pass one through).
d8772d4eb408atagen2026-10-26 04:43 UTC188
 */
d8772d4eb408atagen2026-10-26 04:43 UTC189
function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {
d8772d4eb408atagen2026-10-26 04:43 UTC190
  try {
d8772d4eb408atagen2026-10-26 04:43 UTC191
    const target = new URL(fetchUrl);
d8772d4eb408atagen2026-10-26 04:43 UTC192
    const located = new URL(locatedUrl);
d8772d4eb408atagen2026-10-26 04:43 UTC193
    if (target.origin !== located.origin) return false;
d8772d4eb408atagen2026-10-26 04:43 UTC194
    return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');
d8772d4eb408atagen2026-10-26 04:43 UTC195
  } catch {
d8772d4eb408atagen2026-10-26 04:43 UTC196
    return false;
d8772d4eb408atagen2026-10-26 04:43 UTC197
  }
d8772d4eb408atagen2026-10-26 04:43 UTC198
}
d8772d4eb408atagen2026-10-26 04:43 UTC199
d8772d4eb408atagen2026-10-26 04:43 UTC200
function concatChunks(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
d8772d4eb408atagen2026-10-26 04:43 UTC201
  const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
d8772d4eb408atagen2026-10-26 04:43 UTC202
  const out = new Uint8Array(total);
d8772d4eb408atagen2026-10-26 04:43 UTC203
  let offset = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC204
  for (const part of parts) {
d8772d4eb408atagen2026-10-26 04:43 UTC205
    out.set(part, offset);
d8772d4eb408atagen2026-10-26 04:43 UTC206
    offset += part.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC207
  }
d8772d4eb408atagen2026-10-26 04:43 UTC208
  return out;
d8772d4eb408atagen2026-10-26 04:43 UTC209
}
d8772d4eb408atagen2026-10-26 04:43 UTC210
d8772d4eb408atagen2026-10-26 04:43 UTC211
async function readUpTo(
d8772d4eb408atagen2026-10-26 04:43 UTC212
  reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
d8772d4eb408atagen2026-10-26 04:43 UTC213
  limit: number
d8772d4eb408atagen2026-10-26 04:43 UTC214
): Promise<Uint8Array<ArrayBuffer>> {
d8772d4eb408atagen2026-10-26 04:43 UTC215
  const chunks: Uint8Array[] = [];
d8772d4eb408atagen2026-10-26 04:43 UTC216
  let total = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC217
  while (total < limit) {
d8772d4eb408atagen2026-10-26 04:43 UTC218
    const { value, done } = await reader.read();
d8772d4eb408atagen2026-10-26 04:43 UTC219
    if (done) break;
d8772d4eb408atagen2026-10-26 04:43 UTC220
    chunks.push(value);
d8772d4eb408atagen2026-10-26 04:43 UTC221
    total += value.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC222
  }
d8772d4eb408atagen2026-10-26 04:43 UTC223
  reader.cancel().catch(() => undefined);
d8772d4eb408atagen2026-10-26 04:43 UTC224
  return concatChunks(chunks).subarray(0, Math.min(limit, total));
d8772d4eb408atagen2026-10-26 04:43 UTC225
}
d8772d4eb408atagen2026-10-26 04:43 UTC226
d8772d4eb408atagen2026-10-26 04:43 UTC227
/**
d8772d4eb408atagen2026-10-26 04:43 UTC228
 * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for
d8772d4eb408atagen2026-10-26 04:43 UTC229
 * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte
d8772d4eb408atagen2026-10-26 04:43 UTC230
 * alignment prefix from the very first output, and stops once `limit` bytes have been produced.
d8772d4eb408atagen2026-10-26 04:43 UTC231
 */
d8772d4eb408atagen2026-10-26 04:43 UTC232
async function* decryptedChunks(
d8772d4eb408atagen2026-10-26 04:43 UTC233
  reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
d8772d4eb408atagen2026-10-26 04:43 UTC234
  decryptor: RangeDecryptor | null,
d8772d4eb408atagen2026-10-26 04:43 UTC235
  skipBytes: number,
d8772d4eb408atagen2026-10-26 04:43 UTC236
  dropPrefix: number,
d8772d4eb408atagen2026-10-26 04:43 UTC237
  limit: number
d8772d4eb408atagen2026-10-26 04:43 UTC238
): AsyncGenerator<Uint8Array> {
d8772d4eb408atagen2026-10-26 04:43 UTC239
  let toSkip = skipBytes;
d8772d4eb408atagen2026-10-26 04:43 UTC240
  let toDrop = dropPrefix;
d8772d4eb408atagen2026-10-26 04:43 UTC241
  let remaining = limit;
d8772d4eb408atagen2026-10-26 04:43 UTC242
  while (remaining > 0) {
d8772d4eb408atagen2026-10-26 04:43 UTC243
    const { value, done } = await reader.read();
d8772d4eb408atagen2026-10-26 04:43 UTC244
    if (done) break;
d8772d4eb408atagen2026-10-26 04:43 UTC245
    let chunk = value;
d8772d4eb408atagen2026-10-26 04:43 UTC246
    if (toSkip > 0) {
d8772d4eb408atagen2026-10-26 04:43 UTC247
      if (chunk.byteLength <= toSkip) {
d8772d4eb408atagen2026-10-26 04:43 UTC248
        toSkip -= chunk.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC249
        continue;
d8772d4eb408atagen2026-10-26 04:43 UTC250
      }
d8772d4eb408atagen2026-10-26 04:43 UTC251
      chunk = chunk.subarray(toSkip);
d8772d4eb408atagen2026-10-26 04:43 UTC252
      toSkip = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC253
    }
d8772d4eb408atagen2026-10-26 04:43 UTC254
    const plain = decryptor ? await decryptor.push(chunk) : chunk;
d8772d4eb408atagen2026-10-26 04:43 UTC255
    if (plain.byteLength === 0) continue;
d8772d4eb408atagen2026-10-26 04:43 UTC256
    const emitted = emit(plain);
d8772d4eb408atagen2026-10-26 04:43 UTC257
    if (emitted) yield emitted;
d8772d4eb408atagen2026-10-26 04:43 UTC258
  }
d8772d4eb408atagen2026-10-26 04:43 UTC259
  if (remaining > 0 && decryptor) {
d8772d4eb408atagen2026-10-26 04:43 UTC260
    const emitted = emit(await decryptor.finish());
d8772d4eb408atagen2026-10-26 04:43 UTC261
    if (emitted) yield emitted;
d8772d4eb408atagen2026-10-26 04:43 UTC262
  }
d8772d4eb408atagen2026-10-26 04:43 UTC263
  reader.cancel().catch(() => undefined);
d8772d4eb408atagen2026-10-26 04:43 UTC264
d8772d4eb408atagen2026-10-26 04:43 UTC265
  function emit(chunk: Uint8Array): Uint8Array | null {
d8772d4eb408atagen2026-10-26 04:43 UTC266
    let out = chunk;
d8772d4eb408atagen2026-10-26 04:43 UTC267
    if (toDrop > 0) {
d8772d4eb408atagen2026-10-26 04:43 UTC268
      if (out.byteLength <= toDrop) {
d8772d4eb408atagen2026-10-26 04:43 UTC269
        toDrop -= out.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC270
        return null;
d8772d4eb408atagen2026-10-26 04:43 UTC271
      }
d8772d4eb408atagen2026-10-26 04:43 UTC272
      out = out.subarray(toDrop);
d8772d4eb408atagen2026-10-26 04:43 UTC273
      toDrop = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC274
    }
d8772d4eb408atagen2026-10-26 04:43 UTC275
    if (out.byteLength > remaining) out = out.subarray(0, remaining);
d8772d4eb408atagen2026-10-26 04:43 UTC276
    remaining -= out.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC277
    return out.byteLength > 0 ? out : null;
d8772d4eb408atagen2026-10-26 04:43 UTC278
  }
d8772d4eb408atagen2026-10-26 04:43 UTC279
}
d8772d4eb408atagen2026-10-26 04:43 UTC280
d8772d4eb408atagen2026-10-26 04:43 UTC281
async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {
d8772d4eb408atagen2026-10-26 04:43 UTC282
  const cached = sniffCache.get(mediaId);
d8772d4eb408atagen2026-10-26 04:43 UTC283
  if (cached && cached.expiresAt > Date.now()) return cached.contentType;
d8772d4eb408atagen2026-10-26 04:43 UTC284
  const headers = new Headers({ Range: 'bytes=0-79' });
d8772d4eb408atagen2026-10-26 04:43 UTC285
  if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);
d8772d4eb408atagen2026-10-26 04:43 UTC286
  let upstream: Response;
d8772d4eb408atagen2026-10-26 04:43 UTC287
  try {
d8772d4eb408atagen2026-10-26 04:43 UTC288
    upstream = await fetch(file.url, { headers });
d8772d4eb408atagen2026-10-26 04:43 UTC289
  } catch {
d8772d4eb408atagen2026-10-26 04:43 UTC290
    return null;
d8772d4eb408atagen2026-10-26 04:43 UTC291
  }
d8772d4eb408atagen2026-10-26 04:43 UTC292
  if (upstream.status === 401 && !retried) {
d8772d4eb408atagen2026-10-26 04:43 UTC293
    const fresh = await locate(mediaId, 'Full', true);
d8772d4eb408atagen2026-10-26 04:43 UTC294
    if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {
d8772d4eb408atagen2026-10-26 04:43 UTC295
      return headSniff(mediaId, fresh.location.File, fresh.token, true);
d8772d4eb408atagen2026-10-26 04:43 UTC296
    }
d8772d4eb408atagen2026-10-26 04:43 UTC297
  }
d8772d4eb408atagen2026-10-26 04:43 UTC298
  if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;
d8772d4eb408atagen2026-10-26 04:43 UTC299
  const head = await readUpTo(upstream.body.getReader(), 80);
d8772d4eb408atagen2026-10-26 04:43 UTC300
  let contentType: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC301
  if (file.encryption) {
d8772d4eb408atagen2026-10-26 04:43 UTC302
    const key = await importAttachmentKey(file.encryption.key);
d8772d4eb408atagen2026-10-26 04:43 UTC303
    const iv = decodeAttachmentIv(file.encryption.iv);
d8772d4eb408atagen2026-10-26 04:43 UTC304
    const decryptor = new RangeDecryptor(key, iv, 0);
d8772d4eb408atagen2026-10-26 04:43 UTC305
    contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));
d8772d4eb408atagen2026-10-26 04:43 UTC306
  } else {
d8772d4eb408atagen2026-10-26 04:43 UTC307
    contentType = attachmentContentType(head.subarray(0, 64));
d8772d4eb408atagen2026-10-26 04:43 UTC308
  }
d8772d4eb408atagen2026-10-26 04:43 UTC309
  sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
d8772d4eb408atagen2026-10-26 04:43 UTC310
  return contentType;
d8772d4eb408atagen2026-10-26 04:43 UTC311
}
d8772d4eb408atagen2026-10-26 04:43 UTC312
d8772d4eb408atagen2026-10-26 04:43 UTC313
function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {
d8772d4eb408atagen2026-10-26 04:43 UTC314
  return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));
d8772d4eb408atagen2026-10-26 04:43 UTC315
}
d8772d4eb408atagen2026-10-26 04:43 UTC316
d8772d4eb408atagen2026-10-26 04:43 UTC317
async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {
d8772d4eb408atagen2026-10-26 04:43 UTC318
  const located = entry ?? (await locate(mediaId, 'Full', false));
d8772d4eb408atagen2026-10-26 04:43 UTC319
  if (!located) return wholeFileFallback(mediaId, rangeHeader);
d8772d4eb408atagen2026-10-26 04:43 UTC320
  if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);
d8772d4eb408atagen2026-10-26 04:43 UTC321
  const file = located.location.File;
d8772d4eb408atagen2026-10-26 04:43 UTC322
  const total = file.declared_size;
d8772d4eb408atagen2026-10-26 04:43 UTC323
  if (total == null) return wholeFileFallback(mediaId, rangeHeader);
d8772d4eb408atagen2026-10-26 04:43 UTC324
d8772d4eb408atagen2026-10-26 04:43 UTC325
  const requested = parseByteRange(rangeHeader, total);
d8772d4eb408atagen2026-10-26 04:43 UTC326
  const start = requested?.start ?? 0;
d8772d4eb408atagen2026-10-26 04:43 UTC327
  const end = requested?.end ?? total - 1;
d8772d4eb408atagen2026-10-26 04:43 UTC328
  const alignedStart = start - (start % 16);
d8772d4eb408atagen2026-10-26 04:43 UTC329
d8772d4eb408atagen2026-10-26 04:43 UTC330
  const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });
d8772d4eb408atagen2026-10-26 04:43 UTC331
  if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);
d8772d4eb408atagen2026-10-26 04:43 UTC332
d8772d4eb408atagen2026-10-26 04:43 UTC333
  let upstream: Response;
d8772d4eb408atagen2026-10-26 04:43 UTC334
  try {
d8772d4eb408atagen2026-10-26 04:43 UTC335
    upstream = await fetch(file.url, { headers });
d8772d4eb408atagen2026-10-26 04:43 UTC336
  } catch {
d8772d4eb408atagen2026-10-26 04:43 UTC337
    return new Response('media upstream unreachable', { status: 502 });
d8772d4eb408atagen2026-10-26 04:43 UTC338
  }
d8772d4eb408atagen2026-10-26 04:43 UTC339
  if (upstream.status === 401 && !retried) {
d8772d4eb408atagen2026-10-26 04:43 UTC340
    const fresh = await locate(mediaId, 'Full', true);
d8772d4eb408atagen2026-10-26 04:43 UTC341
    if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);
d8772d4eb408atagen2026-10-26 04:43 UTC342
  }
d8772d4eb408atagen2026-10-26 04:43 UTC343
  if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {
d8772d4eb408atagen2026-10-26 04:43 UTC344
    return new Response('media upstream error', { status: 502 });
d8772d4eb408atagen2026-10-26 04:43 UTC345
  }
d8772d4eb408atagen2026-10-26 04:43 UTC346
d8772d4eb408atagen2026-10-26 04:43 UTC347
  const decryptor = file.encryption
d8772d4eb408atagen2026-10-26 04:43 UTC348
    ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)
d8772d4eb408atagen2026-10-26 04:43 UTC349
    : null;
d8772d4eb408atagen2026-10-26 04:43 UTC350
  const skipBytes = upstream.status === 206 ? 0 : alignedStart;
d8772d4eb408atagen2026-10-26 04:43 UTC351
  const dropPrefix = start - alignedStart;
d8772d4eb408atagen2026-10-26 04:43 UTC352
  const limit = end - start + 1;
d8772d4eb408atagen2026-10-26 04:43 UTC353
d8772d4eb408atagen2026-10-26 04:43 UTC354
  const reader = upstream.body.getReader();
d8772d4eb408atagen2026-10-26 04:43 UTC355
  const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);
d8772d4eb408atagen2026-10-26 04:43 UTC356
d8772d4eb408atagen2026-10-26 04:43 UTC357
  const prefetched: Uint8Array[] = [];
d8772d4eb408atagen2026-10-26 04:43 UTC358
  let prefetchedLen = 0;
d8772d4eb408atagen2026-10-26 04:43 UTC359
  let chunksDone = false;
d8772d4eb408atagen2026-10-26 04:43 UTC360
  let contentType: string | null;
d8772d4eb408atagen2026-10-26 04:43 UTC361
d8772d4eb408atagen2026-10-26 04:43 UTC362
  if (alignedStart === 0) {
d8772d4eb408atagen2026-10-26 04:43 UTC363
    while (prefetchedLen < 64) {
d8772d4eb408atagen2026-10-26 04:43 UTC364
      const step = await chunks.next();
d8772d4eb408atagen2026-10-26 04:43 UTC365
      if (step.done) {
d8772d4eb408atagen2026-10-26 04:43 UTC366
        chunksDone = true;
d8772d4eb408atagen2026-10-26 04:43 UTC367
        break;
d8772d4eb408atagen2026-10-26 04:43 UTC368
      }
d8772d4eb408atagen2026-10-26 04:43 UTC369
      prefetched.push(step.value);
d8772d4eb408atagen2026-10-26 04:43 UTC370
      prefetchedLen += step.value.byteLength;
d8772d4eb408atagen2026-10-26 04:43 UTC371
    }
d8772d4eb408atagen2026-10-26 04:43 UTC372
    contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));
d8772d4eb408atagen2026-10-26 04:43 UTC373
    sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
d8772d4eb408atagen2026-10-26 04:43 UTC374
  } else {
d8772d4eb408atagen2026-10-26 04:43 UTC375
    contentType = await headSniff(mediaId, file, located.token, retried);
d8772d4eb408atagen2026-10-26 04:43 UTC376
  }
d8772d4eb408atagen2026-10-26 04:43 UTC377
d8772d4eb408atagen2026-10-26 04:43 UTC378
  const body = new ReadableStream<Uint8Array>({
d8772d4eb408atagen2026-10-26 04:43 UTC379
    async pull(controller) {
d8772d4eb408atagen2026-10-26 04:43 UTC380
      const next = prefetched.shift();
d8772d4eb408atagen2026-10-26 04:43 UTC381
      if (next) {
d8772d4eb408atagen2026-10-26 04:43 UTC382
        controller.enqueue(next);
d8772d4eb408atagen2026-10-26 04:43 UTC383
        return;
d8772d4eb408atagen2026-10-26 04:43 UTC384
      }
d8772d4eb408atagen2026-10-26 04:43 UTC385
      if (chunksDone) {
d8772d4eb408atagen2026-10-26 04:43 UTC386
        controller.close();
d8772d4eb408atagen2026-10-26 04:43 UTC387
        return;
d8772d4eb408atagen2026-10-26 04:43 UTC388
      }
d8772d4eb408atagen2026-10-26 04:43 UTC389
      const step = await chunks.next();
d8772d4eb408atagen2026-10-26 04:43 UTC390
      if (step.done) controller.close();
d8772d4eb408atagen2026-10-26 04:43 UTC391
      else controller.enqueue(step.value);
d8772d4eb408atagen2026-10-26 04:43 UTC392
    },
d8772d4eb408atagen2026-10-26 04:43 UTC393
    cancel() {
d8772d4eb408atagen2026-10-26 04:43 UTC394
      reader.cancel().catch(() => undefined);
d8772d4eb408atagen2026-10-26 04:43 UTC395
    }
d8772d4eb408atagen2026-10-26 04:43 UTC396
  });
d8772d4eb408atagen2026-10-26 04:43 UTC397
d8772d4eb408atagen2026-10-26 04:43 UTC398
  const responseHeaders = attachmentHeaders(contentType);
d8772d4eb408atagen2026-10-26 04:43 UTC399
  responseHeaders.set('Accept-Ranges', 'bytes');
d8772d4eb408atagen2026-10-26 04:43 UTC400
  if (requested) {
d8772d4eb408atagen2026-10-26 04:43 UTC401
    responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);
d8772d4eb408atagen2026-10-26 04:43 UTC402
    return new Response(body, { status: 206, headers: responseHeaders });
d8772d4eb408atagen2026-10-26 04:43 UTC403
  }
d8772d4eb408atagen2026-10-26 04:43 UTC404
  return new Response(body, { status: 200, headers: responseHeaders });
d8772d4eb408atagen2026-10-26 04:43 UTC405
}
d8772d4eb408atagen2026-10-26 04:43 UTC406
d8772d4eb408atagen2026-10-26 04:43 UTC407
/** Keyed by the request URL, which names image and size, so cached bytes outlive the access token and survive restarts. */
d8772d4eb408atagen2026-10-26 04:43 UTC408
let avatarLoader: ReturnType<typeof createAvatarLoader> | undefined;
d8772d4eb408atagen2026-10-26 04:43 UTC409
d8772d4eb408atagen2026-10-26 04:43 UTC410
function cachedAvatar(url: string, query: string): Promise<Response> {
d8772d4eb408atagen2026-10-26 04:43 UTC411
  avatarLoader ??= createAvatarLoader(
d8772d4eb408atagen2026-10-26 04:43 UTC412
    async () => caches.open(AVATAR_CACHE),
d8772d4eb408atagen2026-10-26 04:43 UTC413
    async (q) => mediaResponse(await requestMedia('avatar', Object.fromEntries(new URLSearchParams(q))), null, false)
d8772d4eb408atagen2026-10-26 04:43 UTC414
  );
d8772d4eb408atagen2026-10-26 04:43 UTC415
  return avatarLoader(url, query);
d8772d4eb408atagen2026-10-26 04:43 UTC416
}
d8772d4eb408atagen2026-10-26 04:43 UTC417
d8772d4eb408atagen2026-10-26 04:43 UTC418
export function handleMediaFetch(event: FetchEvent): boolean {
d8772d4eb408atagen2026-10-26 04:43 UTC419
  const url = new URL(event.request.url);
d8772d4eb408atagen2026-10-26 04:43 UTC420
  const range = event.request.headers.get('Range');
d8772d4eb408atagen2026-10-26 04:43 UTC421
d8772d4eb408atagen2026-10-26 04:43 UTC422
  if (url.pathname === '/api/media') {
d8772d4eb408atagen2026-10-26 04:43 UTC423
    const mxc = url.searchParams.get('mxc');
d8772d4eb408atagen2026-10-26 04:43 UTC424
    if (mxc) {
d8772d4eb408atagen2026-10-26 04:43 UTC425
      const size = url.searchParams.get('size') ?? '256';
d8772d4eb408atagen2026-10-26 04:43 UTC426
      event.respondWith(cachedAvatar(event.request.url, new URLSearchParams({ mxc, size }).toString()));
d8772d4eb408atagen2026-10-26 04:43 UTC427
      return true;
d8772d4eb408atagen2026-10-26 04:43 UTC428
    }
d8772d4eb408atagen2026-10-26 04:43 UTC429
  }
d8772d4eb408atagen2026-10-26 04:43 UTC430
  if (url.pathname === '/api/message-media') {
d8772d4eb408atagen2026-10-26 04:43 UTC431
    const mediaId = url.searchParams.get('media_id');
d8772d4eb408atagen2026-10-26 04:43 UTC432
    const size = url.searchParams.get('size');
d8772d4eb408atagen2026-10-26 04:43 UTC433
    if (mediaId && size === 'Full') {
d8772d4eb408atagen2026-10-26 04:43 UTC434
      event.respondWith(streamFullMedia(mediaId, range));
d8772d4eb408atagen2026-10-26 04:43 UTC435
      return true;
d8772d4eb408atagen2026-10-26 04:43 UTC436
    }
d8772d4eb408atagen2026-10-26 04:43 UTC437
    if (mediaId && size) {
d8772d4eb408atagen2026-10-26 04:43 UTC438
      event.respondWith(requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true)));
d8772d4eb408atagen2026-10-26 04:43 UTC439
      return true;
d8772d4eb408atagen2026-10-26 04:43 UTC440
    }
d8772d4eb408atagen2026-10-26 04:43 UTC441
  }
d8772d4eb408atagen2026-10-26 04:43 UTC442
  return false;
d8772d4eb408atagen2026-10-26 04:43 UTC443
}