///
import { ctRange, decryptRange } from "./crypto";
import { guessMime } from "./utils";
declare const self: ServiceWorkerGlobalScope;
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
interface Entry {
key: CryptoKey;
fileName: string;
fileLen: number;
}
const entries = new Map();
// Parse Range header, resolving against total file size.
// Returns null if no valid range, otherwise [start, end] inclusive.
function parseRange(header: string | null, fileLen: number): [number, number] | null {
if (!header || fileLen <= 0) return null;
const m = header.match(/^bytes=(\d*)-(\d*)$/);
if (!m) return null;
let start: number, end: number;
if (!m[1]) {
start = Math.max(0, fileLen - parseInt(m[2]!));
end = fileLen - 1;
} else {
start = parseInt(m[1]);
end = m[2] ? Math.min(parseInt(m[2]), fileLen - 1) : fileLen - 1;
}
return start <= end ? [start, end] : null;
}
self.addEventListener("message", (event: ExtendableMessageEvent) => {
const { type, id, key, fileName, fileLen } = event.data;
if (type === "register-key") {
entries.set(id, { key, fileName, fileLen });
event.ports[0]?.postMessage("ok");
} else if (type === "claim") {
event.waitUntil(self.clients.claim());
}
});
self.addEventListener("fetch", (event: FetchEvent) => {
const { pathname } = new URL(event.request.url);
const match = pathname.match(/^\/([^/]+)\/decrypted$/);
if (!match) return;
const [, id] = match as [string, string];
event.respondWith(
(async () => {
const entry = entries.get(id);
if (!entry) return new Response("Key not registered.", { status: 400 });
const { key, fileName, fileLen } = entry;
const mime = guessMime(fileName);
const range = parseRange(event.request.headers.get("Range"), fileLen);
const plainStart = range?.[0] ?? 0;
const plainEnd = range?.[1] ?? Math.max(fileLen - 1, 0);
const responseLen = plainEnd - plainStart + 1;
const headers: Record = {
"Content-Type": mime,
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"Content-Length": String(responseLen),
"Accept-Ranges": "bytes",
};
// Fetch ciphertext (range-narrowed for partial requests, full for non-range)
const fetchHeaders: Record = {};
if (range) {
const [ctStart, ctEnd] = ctRange(plainStart, plainEnd);
fetchHeaders.Range = `bytes=${ctStart}-${ctEnd}`;
}
const ctResp = await fetch(`/api/file/${id}`, { headers: fetchHeaders });
if (!ctResp.ok && ctResp.status !== 206) {
return new Response(
ctResp.status === 404 ? "File not found or expired." : "Download failed.",
{ status: ctResp.status },
);
}
const stream = decryptRange(ctResp.body!.getReader(), key, fileLen, plainStart, plainEnd);
if (range) {
headers["Content-Range"] = `bytes ${plainStart}-${plainEnd}/${fileLen}`;
return new Response(stream, { status: 206, headers });
}
return new Response(stream, { headers });
})().catch(
(err: any) => new Response(err.message ?? "error", { status: err.status ?? 500 }),
),
);
});