From ba9cb68ef98235dbbbbd404e372aa42a290c4604 Mon Sep 17 00:00:00 2001 From: David Buchanan Date: Wed, 11 Mar 2026 20:52:18 +0000 Subject: [PATCH] decide preview format on upload --- web/src/lib/crypto.test.ts | 24 +++++++++++++++-------- web/src/lib/crypto.ts | 11 +++++++---- web/src/lib/utils.ts | 11 +++++++++++ web/src/pages/Upload.tsx | 3 ++- web/src/pages/View.tsx | 40 +++++++++++++++++++------------------- 5 files changed, 56 insertions(+), 33 deletions(-) diff --git a/web/src/lib/crypto.test.ts b/web/src/lib/crypto.test.ts index ca920cb..ac3840d 100644 --- a/web/src/lib/crypto.test.ts +++ b/web/src/lib/crypto.test.ts @@ -13,23 +13,31 @@ async function makeKey() { } describe("encryptMeta / decryptMeta round-trip", () => { - it("recovers filename and file length", async () => { + it("recovers filename, file length, and presentation", async () => { const { key } = await makeKey(); - const ct = await encryptMeta("hello.txt", 42, key); - const { fileName, fileLen } = await decryptMeta(ct, key); + const ct = await encryptMeta("hello.txt", 42, "text", key); + const { fileName, fileLen, presentation } = await decryptMeta(ct, key); expect(fileName).toBe("hello.txt"); expect(fileLen).toBe(42); + expect(presentation).toBe("text"); }); it("ciphertext is always exactly 4096 bytes", async () => { const { key } = await makeKey(); - const ct = await encryptMeta("hello.txt", 42, key); + const ct = await encryptMeta("hello.txt", 42, "text", key); expect(ct.byteLength).toBe(4096); }); + it("handles null presentation", async () => { + const { key } = await makeKey(); + const ct = await encryptMeta("archive.zip", 1000, null, key); + const { presentation } = await decryptMeta(ct, key); + expect(presentation).toBeNull(); + }); + it("handles a unicode filename", async () => { const { key } = await makeKey(); - const ct = await encryptMeta("日本語ファイル.txt", 0, key); + const ct = await encryptMeta("日本語ファイル.txt", 0, "text", key); const { fileName, fileLen } = await decryptMeta(ct, key); expect(fileName).toBe("日本語ファイル.txt"); expect(fileLen).toBe(0); @@ -38,13 +46,13 @@ describe("encryptMeta / decryptMeta round-trip", () => { it("decryption fails with wrong key", async () => { const { key: key1 } = await makeKey(); const { key: key2 } = await makeKey(); - const ct = await encryptMeta("f.txt", 1, key1); + const ct = await encryptMeta("f.txt", 1, "text", key1); expect(decryptMeta(ct, key2)).rejects.toThrow(); }); it("throws when filename exceeds 2048 bytes", async () => { const { key } = await makeKey(); - expect(encryptMeta("a".repeat(2049), 0, key)).rejects.toThrow("Filename too long"); + expect(encryptMeta("a".repeat(2049), 0, null, key)).rejects.toThrow("Filename too long"); }); }); @@ -115,7 +123,7 @@ describe("encrypt / decrypt round-trip", () => { // Encrypt a 13-byte payload both ways — they must differ const payload = new TextEncoder().encode("Hello, world!"); const bodyCt = await encrypt(payload.buffer, key); - const metaCt = await encryptMeta("Hello, world!", 0, key); + const metaCt = await encryptMeta("Hello, world!", 0, null, key); // They must not be equal (different IVs) expect(bodyCt.slice(0, metaCt.byteLength)).not.toEqual(metaCt); }); diff --git a/web/src/lib/crypto.ts b/web/src/lib/crypto.ts index 8a132a1..b55a265 100644 --- a/web/src/lib/crypto.ts +++ b/web/src/lib/crypto.ts @@ -1,3 +1,5 @@ +import type { Presentation } from "./utils"; + // AES-256-GCM helpers using Web Crypto API // Each upload gets a unique key, so fixed IVs are safe (one use per key). // Metadata (filename + size) is encrypted with IV 0. @@ -36,15 +38,16 @@ export async function importKey(encoded: string) { // so the ciphertext (plaintext + 16-byte GCM tag) is always exactly 4096 bytes. const META_PLAIN_LEN = 4096 - 16; -// Encrypt metadata JSON { name, size } with IV 0, padded to META_PLAIN_LEN bytes +// Encrypt metadata JSON { name, size, presentation } with IV 0, padded to META_PLAIN_LEN bytes export async function encryptMeta( fileName: string, fileLen: number, + presentation: Presentation, key: CryptoKey, ) { const nameBytes = new TextEncoder().encode(fileName); if (nameBytes.length > 2048) throw new Error("Filename too long"); - const json = JSON.stringify({ name: fileName, size: fileLen }); + const json = JSON.stringify({ name: fileName, size: fileLen, presentation }); const jsonBytes = new TextEncoder().encode(json); if (jsonBytes.length > META_PLAIN_LEN) throw new Error("Metadata too large"); // Pad to exactly META_PLAIN_LEN with trailing spaces (valid JSON whitespace) @@ -69,8 +72,8 @@ export async function decryptMeta( key, ciphertext, ); - const { name, size } = JSON.parse(new TextDecoder().decode(plain)); - return { fileName: name as string, fileLen: size as number }; + const { name, size, presentation } = JSON.parse(new TextDecoder().decode(plain)); + return { fileName: name as string, fileLen: size as number, presentation: presentation as Presentation }; } // Encrypt file body with IV 1, padded to 4K boundary diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 6db4598..fda3565 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -76,6 +76,17 @@ export function getExt(name: string): string { return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; } +export type Presentation = "image" | "video" | "audio" | "text" | null; + +export function getPresentation(name: string): Presentation { + const ext = getExt(name); + if (IMAGE_EXTS.has(ext)) return "image"; + if (VIDEO_EXTS.has(ext)) return "video"; + if (AUDIO_EXTS.has(ext)) return "audio"; + if (TEXT_EXTS.has(ext)) return "text"; + return null; +} + export function formatBytes(n: number): string { if (n < 1000) return `${n} B`; if (n < 1e6) return `${parseFloat((n / 1e3).toFixed(1))} KB`; diff --git a/web/src/pages/Upload.tsx b/web/src/pages/Upload.tsx index 08c373a..4ea9acd 100644 --- a/web/src/pages/Upload.tsx +++ b/web/src/pages/Upload.tsx @@ -8,6 +8,7 @@ import { } from "solid-js"; import { generateKey, encryptMeta } from "../lib/crypto"; +import { getPresentation } from "../lib/utils"; import { btnClass, btnStyle, fadeIn } from "../lib/ui"; import { formatBytes } from "../lib/utils"; @@ -173,7 +174,7 @@ export default function Upload() { const fileName = f.name || "file"; const [buffer, metaCiphertext] = await Promise.all([ f.arrayBuffer(), - encryptMeta(fileName, f.size, key), + encryptMeta(fileName, f.size, getPresentation(fileName), key), ]); const ciphertext = await new Promise>( (resolve, reject) => { diff --git a/web/src/pages/View.tsx b/web/src/pages/View.tsx index eeb5d7a..aa05968 100644 --- a/web/src/pages/View.tsx +++ b/web/src/pages/View.tsx @@ -7,12 +7,10 @@ import { formatExpiry, getExt, triggerDownload, - IMAGE_EXTS, - TEXT_EXTS, - VIDEO_EXTS, IMAGE_MIME, VIDEO_MIME, AUDIO_MIME, + type Presentation, } from "../lib/utils"; const ghostClass = @@ -39,6 +37,7 @@ export default function View() { const [imageSrc, setImageSrc] = createSignal(""); const [mediaSrc, setMediaSrc] = createSignal(""); const [fileName, setFileName] = createSignal(""); + const [presentation, setPresentation] = createSignal(null); const [copied, setCopied] = createSignal(false); let decryptedBlob: Blob | null = null; @@ -82,9 +81,10 @@ export default function View() { try { const metaBytes = Uint8Array.fromBase64(info.metaEnc); - const { fileName: name, fileLen } = await decryptMeta(metaBytes, key!); + const { fileName: name, fileLen, presentation: pres } = await decryptMeta(metaBytes, key!); setFileName(name); setSize(fileLen); + setPresentation(pres); } catch { setError("Failed to decrypt metadata."); setStage("error"); @@ -147,26 +147,26 @@ export default function View() { const name = fileName(); const ext = getExt(name); + const pres = presentation(); if (burnAfterRead()) setBurned(true); - const mime = - IMAGE_MIME[ext] || VIDEO_MIME[ext] || AUDIO_MIME[ext] || undefined; - if (mime) { - const blob = new Blob([fileData], { type: mime }); + if (pres === "image") { + const blob = new Blob([fileData], { type: IMAGE_MIME[ext] }); decryptedBlob = blob; - const url = URL.createObjectURL(blob); - if (IMAGE_EXTS.has(ext)) { - setImageSrc(url); - setContentType("image"); - } else if (VIDEO_EXTS.has(ext)) { - setMediaSrc(url); - setContentType("video"); - } else { - setMediaSrc(url); - setContentType("audio"); - } - } else if (TEXT_EXTS.has(ext)) { + setImageSrc(URL.createObjectURL(blob)); + setContentType("image"); + } else if (pres === "video") { + const blob = new Blob([fileData], { type: VIDEO_MIME[ext] }); + decryptedBlob = blob; + setMediaSrc(URL.createObjectURL(blob)); + setContentType("video"); + } else if (pres === "audio") { + const blob = new Blob([fileData], { type: AUDIO_MIME[ext] }); + decryptedBlob = blob; + setMediaSrc(URL.createObjectURL(blob)); + setContentType("audio"); + } else if (pres === "text") { setTextContent(new TextDecoder().decode(fileData)); setContentType("text"); } else { -- knotmirror 0.0.1