atagen

malice

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