atagen

malice

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