atagen

malice

public
Star0
Watch0
d07bca5da4ae8096444c8411d8ca0c4bf01dee66
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/sw.tsTypescript492 lines17.4 KB
1
import { statusForKind } from './lib/wire-status';
2
import { decodeAttachmentIv, importAttachmentKey, RangeDecryptor } from './lib/media-decrypt';
3
import { attachmentContentType } from './lib/media-sniff';
4
import { createAvatarLoader } from './lib/avatar-cache';
5
6
// Prepended by vite.config.ts's precacheManifestPlugin: every emitted chunk,
7
// asset, and the wasm, as root-relative URLs.
8
const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };
9
10
const CACHE_NAME = 'malice-shell-v1';
11
const AVATAR_CACHE = 'malice-avatars-v3';
12
13
sw.addEventListener('install', (event) => {
14
  event.waitUntil(
15
    (async () => {
16
      const cache = await caches.open(CACHE_NAME);
17
      await cache.addAll(['/', '/index.html', ...sw.__MALICE_PRECACHE__]);
18
    })()
19
  );
20
  void sw.skipWaiting();
21
});
22
23
sw.addEventListener('activate', (event) => {
24
  event.waitUntil(
25
    (async () => {
26
      const keys = await caches.keys();
27
      await Promise.all(keys.filter((key) => key !== CACHE_NAME && key !== AVATAR_CACHE).map((key) => caches.delete(key)));
28
      const shell = await caches.open(CACHE_NAME);
29
      const keep = new Set(['/', '/index.html', ...sw.__MALICE_PRECACHE__]);
30
      for (const request of await shell.keys()) {
31
        const path = new URL(request.url).pathname;
32
        if (path.startsWith('/assets/') && !keep.has(path)) await shell.delete(request);
33
      }
34
      await sw.clients.claim();
35
    })()
36
  );
37
});
38
39
interface MediaReply {
40
  bytes?: Uint8Array<ArrayBuffer>;
41
  content_type?: string | null;
42
  error?: { kind: string; message: string };
43
}
44
45
interface AttachmentEncryptionWire {
46
  key: string;
47
  iv: string;
48
  sha256: string;
49
}
50
51
interface MediaFileWire {
52
  url: string;
53
  mimetype: string | null;
54
  declared_size: number | null;
55
  encryption: AttachmentEncryptionWire | null;
56
}
57
58
type MediaLocationWire = { File: MediaFileWire } | { ServerThumbnail: { url: string; fallback: MediaFileWire } } | 'Local';
59
60
interface LocateReply {
61
  location?: MediaLocationWire;
62
  token?: string | null;
63
  error?: { kind: string; message: string };
64
}
65
66
const MEDIA_TIMEOUT_MS = 30000;
67
const mediaChannel = new BroadcastChannel('malice-media');
68
const pendingMedia = new Map<string, (reply: MediaReply & LocateReply) => void>();
69
let nextMediaRequestId = 0;
70
71
mediaChannel.onmessage = (ev: MessageEvent) => {
72
  const msg = ev.data as MediaReply & LocateReply & { id: string };
73
  const resolve = pendingMedia.get(msg.id);
74
  if (!resolve) return;
75
  pendingMedia.delete(msg.id);
76
  resolve(msg);
77
};
78
79
function requestMedia(kind: 'avatar' | 'media', fields: Record<string, string>): Promise<MediaReply | null> {
80
  const id = `${Date.now()}-${nextMediaRequestId++}`;
81
  return new Promise((resolve) => {
82
    const timer = setTimeout(() => {
83
      pendingMedia.delete(id);
84
      resolve(null);
85
    }, MEDIA_TIMEOUT_MS);
86
    pendingMedia.set(id, (reply) => {
87
      clearTimeout(timer);
88
      resolve(reply);
89
    });
90
    mediaChannel.postMessage({ id, kind, ...fields });
91
  });
92
}
93
94
interface LocateEntry {
95
  location: MediaLocationWire;
96
  token: string | null;
97
  expiresAt: number;
98
}
99
100
const LOCATE_TTL_MS = 5 * 60 * 1000;
101
const locateCache = new Map<string, LocateEntry>();
102
const sniffCache = new Map<string, { contentType: string | null; expiresAt: number }>();
103
104
function requestLocate(mediaId: string, size: string): Promise<{ location: MediaLocationWire; token: string | null } | null> {
105
  const id = `${Date.now()}-${nextMediaRequestId++}`;
106
  return new Promise((resolve) => {
107
    const timer = setTimeout(() => {
108
      pendingMedia.delete(id);
109
      resolve(null);
110
    }, MEDIA_TIMEOUT_MS);
111
    pendingMedia.set(id, (reply) => {
112
      clearTimeout(timer);
113
      resolve(reply.error || !reply.location ? null : { location: reply.location, token: reply.token ?? null });
114
    });
115
    mediaChannel.postMessage({ id, kind: 'locate', media_id: mediaId, size });
116
  });
117
}
118
119
/** In-memory only: the access token never touches Cache Storage or IndexedDB, and expires with the entry. */
120
async function locate(mediaId: string, size: string, force: boolean): Promise<LocateEntry | null> {
121
  if (!force) {
122
    const cached = locateCache.get(mediaId);
123
    if (cached && cached.expiresAt > Date.now()) return cached;
124
  }
125
  const reply = await requestLocate(mediaId, size);
126
  if (!reply) return null;
127
  const entry: LocateEntry = { ...reply, expiresAt: Date.now() + LOCATE_TTL_MS };
128
  locateCache.set(mediaId, entry);
129
  return entry;
130
}
131
132
interface RangeSlice {
133
  body: Uint8Array<ArrayBuffer>;
134
  status: number;
135
  contentRange?: string;
136
}
137
138
/** The engine hands back the whole attachment at once, so seeking in <video>/<audio> needs this SW to slice it. */
139
function sliceForRange(bytes: Uint8Array<ArrayBuffer>, rangeHeader: string | null): RangeSlice {
140
  const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
141
  if (!match) return { body: bytes, status: 200 };
142
  const total = bytes.byteLength;
143
  let start = match[1] ? parseInt(match[1], 10) : undefined;
144
  let end = match[2] ? parseInt(match[2], 10) : undefined;
145
  if (start === undefined && end !== undefined) {
146
    start = Math.max(total - end, 0);
147
    end = total - 1;
148
  } else if (end === undefined) {
149
    end = total - 1;
150
  }
151
  if (start === undefined || start > end || start >= total) return { body: bytes, status: 200 };
152
  end = Math.min(end, total - 1);
153
  return { body: bytes.subarray(start, end + 1), status: 206, contentRange: `bytes ${start}-${end}/${total}` };
154
}
155
156
function mediaResponse(reply: MediaReply | null, rangeHeader: string | null, attachment: boolean): Response {
157
  if (!reply) return new Response(null, { status: 504 });
158
  if (reply.error) return new Response(reply.error.message, { status: statusForKind(reply.error.kind) });
159
  const bytes = reply.bytes ?? new Uint8Array();
160
  const { body, status, contentRange } = sliceForRange(bytes, rangeHeader);
161
  const headers = new Headers({
162
    'Content-Type': reply.content_type ?? 'application/octet-stream',
163
    'Cache-Control': attachment ? 'private, max-age=3600' : 'private, max-age=31536000, immutable',
164
    'X-Content-Type-Options': 'nosniff'
165
  });
166
  if (attachment) {
167
    headers.set('Content-Disposition', reply.content_type ? 'inline' : 'attachment');
168
    headers.set('Content-Security-Policy', 'sandbox');
169
  }
170
  if (contentRange) {
171
    headers.set('Content-Range', contentRange);
172
    headers.set('Accept-Ranges', 'bytes');
173
  }
174
  return new Response(body, { status, headers });
175
}
176
177
interface ByteRange {
178
  start: number;
179
  end: number;
180
}
181
182
/** Unlike sliceForRange, the total comes from the server's declared_size, not a buffer already in hand. */
183
function parseByteRange(rangeHeader: string | null, total: number): ByteRange | null {
184
  const match = rangeHeader ? /^bytes=(\d*)-(\d*)$/.exec(rangeHeader) : null;
185
  if (!match) return null;
186
  let start = match[1] ? parseInt(match[1], 10) : undefined;
187
  let end = match[2] ? parseInt(match[2], 10) : undefined;
188
  if (start === undefined && end !== undefined) {
189
    start = Math.max(total - end, 0);
190
    end = total - 1;
191
  } else if (end === undefined) {
192
    start = start ?? 0;
193
    end = total - 1;
194
  }
195
  if (start === undefined || start > end || start >= total) return null;
196
  return { start, end: Math.min(end, total - 1) };
197
}
198
199
function attachmentHeaders(contentType: string | null): Headers {
200
  return new Headers({
201
    'Content-Type': contentType ?? 'application/octet-stream',
202
    'Cache-Control': 'private, max-age=3600',
203
    'X-Content-Type-Options': 'nosniff',
204
    'Content-Disposition': contentType ? 'inline' : 'attachment',
205
    'Content-Security-Policy': 'sandbox'
206
  });
207
}
208
209
/**
210
 * The Authorization header carries the account's access token, so it's only worth the risk on the
211
 * exact authenticated media endpoints, and only when the URL being fetched is the one `locate` named
212
 * (never a redirect target, should a future caller pass one through).
213
 */
214
function shouldAuthorize(fetchUrl: string, locatedUrl: string): boolean {
215
  try {
216
    const target = new URL(fetchUrl);
217
    const located = new URL(locatedUrl);
218
    if (target.origin !== located.origin) return false;
219
    return target.pathname.startsWith('/_matrix/client/v1/media/') || target.pathname.startsWith('/_matrix/media/');
220
  } catch {
221
    return false;
222
  }
223
}
224
225
function concatChunks(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
226
  const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
227
  const out = new Uint8Array(total);
228
  let offset = 0;
229
  for (const part of parts) {
230
    out.set(part, offset);
231
    offset += part.byteLength;
232
  }
233
  return out;
234
}
235
236
async function readUpTo(
237
  reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
238
  limit: number
239
): Promise<Uint8Array<ArrayBuffer>> {
240
  const chunks: Uint8Array[] = [];
241
  let total = 0;
242
  while (total < limit) {
243
    const { value, done } = await reader.read();
244
    if (done) break;
245
    chunks.push(value);
246
    total += value.byteLength;
247
  }
248
  reader.cancel().catch(() => undefined);
249
  return concatChunks(chunks).subarray(0, Math.min(limit, total));
250
}
251
252
/**
253
 * Ciphertext in, plaintext out: skips the bytes the upstream sent but the caller didn't ask for
254
 * (only nonzero when the upstream ignored the Range request), decrypts, drops the sub-16-byte
255
 * alignment prefix from the very first output, and stops once `limit` bytes have been produced.
256
 */
257
async function* decryptedChunks(
258
  reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>>,
259
  decryptor: RangeDecryptor | null,
260
  skipBytes: number,
261
  dropPrefix: number,
262
  limit: number
263
): AsyncGenerator<Uint8Array> {
264
  let toSkip = skipBytes;
265
  let toDrop = dropPrefix;
266
  let remaining = limit;
267
  while (remaining > 0) {
268
    const { value, done } = await reader.read();
269
    if (done) break;
270
    let chunk = value;
271
    if (toSkip > 0) {
272
      if (chunk.byteLength <= toSkip) {
273
        toSkip -= chunk.byteLength;
274
        continue;
275
      }
276
      chunk = chunk.subarray(toSkip);
277
      toSkip = 0;
278
    }
279
    const plain = decryptor ? await decryptor.push(chunk) : chunk;
280
    if (plain.byteLength === 0) continue;
281
    const emitted = emit(plain);
282
    if (emitted) yield emitted;
283
  }
284
  if (remaining > 0 && decryptor) {
285
    const emitted = emit(await decryptor.finish());
286
    if (emitted) yield emitted;
287
  }
288
  reader.cancel().catch(() => undefined);
289
290
  function emit(chunk: Uint8Array): Uint8Array | null {
291
    let out = chunk;
292
    if (toDrop > 0) {
293
      if (out.byteLength <= toDrop) {
294
        toDrop -= out.byteLength;
295
        return null;
296
      }
297
      out = out.subarray(toDrop);
298
      toDrop = 0;
299
    }
300
    if (out.byteLength > remaining) out = out.subarray(0, remaining);
301
    remaining -= out.byteLength;
302
    return out.byteLength > 0 ? out : null;
303
  }
304
}
305
306
async function headSniff(mediaId: string, file: MediaFileWire, token: string | null, retried: boolean): Promise<string | null> {
307
  const cached = sniffCache.get(mediaId);
308
  if (cached && cached.expiresAt > Date.now()) return cached.contentType;
309
  const headers = new Headers({ Range: 'bytes=0-79' });
310
  if (shouldAuthorize(file.url, file.url) && token) headers.set('Authorization', `Bearer ${token}`);
311
  let upstream: Response;
312
  try {
313
    upstream = await fetch(file.url, { headers });
314
  } catch {
315
    return null;
316
  }
317
  if (upstream.status === 401 && !retried) {
318
    const fresh = await locate(mediaId, 'Full', true);
319
    if (fresh && typeof fresh.location === 'object' && 'File' in fresh.location) {
320
      return headSniff(mediaId, fresh.location.File, fresh.token, true);
321
    }
322
  }
323
  if (!upstream.body || (!upstream.ok && upstream.status !== 206)) return null;
324
  const head = await readUpTo(upstream.body.getReader(), 80);
325
  let contentType: string | null;
326
  if (file.encryption) {
327
    const key = await importAttachmentKey(file.encryption.key);
328
    const iv = decodeAttachmentIv(file.encryption.iv);
329
    const decryptor = new RangeDecryptor(key, iv, 0);
330
    contentType = attachmentContentType(concatChunks([await decryptor.push(head), await decryptor.finish()]).subarray(0, 64));
331
  } else {
332
    contentType = attachmentContentType(head.subarray(0, 64));
333
  }
334
  sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
335
  return contentType;
336
}
337
338
function wholeFileFallback(mediaId: string, rangeHeader: string | null): Promise<Response> {
339
  return requestMedia('media', { media_id: mediaId, size: 'Full' }).then((reply) => mediaResponse(reply, rangeHeader, true));
340
}
341
342
async function streamFullMedia(mediaId: string, rangeHeader: string | null, entry?: LocateEntry, retried = false): Promise<Response> {
343
  const located = entry ?? (await locate(mediaId, 'Full', false));
344
  if (!located) return wholeFileFallback(mediaId, rangeHeader);
345
  if (typeof located.location !== 'object' || !('File' in located.location)) return wholeFileFallback(mediaId, rangeHeader);
346
  const file = located.location.File;
347
  const total = file.declared_size;
348
  if (total == null) return wholeFileFallback(mediaId, rangeHeader);
349
350
  const requested = parseByteRange(rangeHeader, total);
351
  const start = requested?.start ?? 0;
352
  const end = requested?.end ?? total - 1;
353
  const alignedStart = start - (start % 16);
354
355
  const headers = new Headers({ Range: `bytes=${alignedStart}-${end}` });
356
  if (shouldAuthorize(file.url, file.url) && located.token) headers.set('Authorization', `Bearer ${located.token}`);
357
358
  let upstream: Response;
359
  try {
360
    upstream = await fetch(file.url, { headers });
361
  } catch {
362
    return new Response('media upstream unreachable', { status: 502 });
363
  }
364
  if (upstream.status === 401 && !retried) {
365
    const fresh = await locate(mediaId, 'Full', true);
366
    if (fresh) return streamFullMedia(mediaId, rangeHeader, fresh, true);
367
  }
368
  if (!upstream.body || (!upstream.ok && upstream.status !== 206)) {
369
    return new Response('media upstream error', { status: 502 });
370
  }
371
372
  const decryptor = file.encryption
373
    ? new RangeDecryptor(await importAttachmentKey(file.encryption.key), decodeAttachmentIv(file.encryption.iv), alignedStart / 16)
374
    : null;
375
  const skipBytes = upstream.status === 206 ? 0 : alignedStart;
376
  const dropPrefix = start - alignedStart;
377
  const limit = end - start + 1;
378
379
  const reader = upstream.body.getReader();
380
  const chunks = decryptedChunks(reader, decryptor, skipBytes, dropPrefix, limit);
381
382
  const prefetched: Uint8Array[] = [];
383
  let prefetchedLen = 0;
384
  let chunksDone = false;
385
  let contentType: string | null;
386
387
  if (alignedStart === 0) {
388
    while (prefetchedLen < 64) {
389
      const step = await chunks.next();
390
      if (step.done) {
391
        chunksDone = true;
392
        break;
393
      }
394
      prefetched.push(step.value);
395
      prefetchedLen += step.value.byteLength;
396
    }
397
    contentType = attachmentContentType(concatChunks(prefetched).subarray(0, Math.min(64, prefetchedLen)));
398
    sniffCache.set(mediaId, { contentType, expiresAt: Date.now() + LOCATE_TTL_MS });
399
  } else {
400
    contentType = await headSniff(mediaId, file, located.token, retried);
401
  }
402
403
  const body = new ReadableStream<Uint8Array>({
404
    async pull(controller) {
405
      const next = prefetched.shift();
406
      if (next) {
407
        controller.enqueue(next);
408
        return;
409
      }
410
      if (chunksDone) {
411
        controller.close();
412
        return;
413
      }
414
      const step = await chunks.next();
415
      if (step.done) controller.close();
416
      else controller.enqueue(step.value);
417
    },
418
    cancel() {
419
      reader.cancel().catch(() => undefined);
420
    }
421
  });
422
423
  const responseHeaders = attachmentHeaders(contentType);
424
  responseHeaders.set('Accept-Ranges', 'bytes');
425
  if (requested) {
426
    responseHeaders.set('Content-Range', `bytes ${start}-${end}/${total}`);
427
    return new Response(body, { status: 206, headers: responseHeaders });
428
  }
429
  return new Response(body, { status: 200, headers: responseHeaders });
430
}
431
432
async function cacheFirst(request: Request): Promise<Response> {
433
  const cached = await caches.match(request);
434
  if (cached) return cached;
435
  const response = await fetch(request);
436
  if (response.ok) {
437
    const cache = await caches.open(CACHE_NAME);
438
    void cache.put(request, response.clone());
439
  }
440
  return response;
441
}
442
443
/** Keyed by the request URL, which names image and size, so cached bytes outlive the access token and survive restarts. */
444
const cachedAvatar = createAvatarLoader(
445
  async () => caches.open(AVATAR_CACHE),
446
  async (query) => mediaResponse(await requestMedia('avatar', Object.fromEntries(new URLSearchParams(query))), null, false)
447
);
448
449
async function networkFirstNavigation(request: Request): Promise<Response> {
450
  try {
451
    return await fetch(request);
452
  } catch {
453
    const cached = await caches.match('/index.html');
454
    if (cached) return cached;
455
    throw new Error('offline and no cached shell');
456
  }
457
}
458
459
sw.addEventListener('fetch', (event) => {
460
  const url = new URL(event.request.url);
461
  const range = event.request.headers.get('Range');
462
463
  if (url.pathname === '/api/media') {
464
    const mxc = url.searchParams.get('mxc');
465
    if (mxc) {
466
      const size = url.searchParams.get('size') ?? '256';
467
      event.respondWith(cachedAvatar(event.request.url, new URLSearchParams({ mxc, size }).toString()));
468
      return;
469
    }
470
  }
471
  if (url.pathname === '/api/message-media') {
472
    const mediaId = url.searchParams.get('media_id');
473
    const size = url.searchParams.get('size');
474
    if (mediaId && size === 'Full') {
475
      event.respondWith(streamFullMedia(mediaId, range));
476
      return;
477
    }
478
    if (mediaId && size) {
479
      event.respondWith(
480
        requestMedia('media', { media_id: mediaId, size }).then((reply) => mediaResponse(reply, range, true))
481
      );
482
      return;
483
    }
484
  }
485
  if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/twemoji/')) {
486
    event.respondWith(cacheFirst(event.request));
487
    return;
488
  }
489
  if (event.request.mode === 'navigate') {
490
    event.respondWith(networkFirstNavigation(event.request));
491
  }
492
});