Initial commit

This commit is contained in:
Zoe
2026-08-07 22:13:25 -05:00
commit 8006fd67f3
40 changed files with 11017 additions and 0 deletions

46
app/app.vue Normal file
View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
useHead({
titleTemplate: (title) =>
title ? `${title} · Quickdrop` : "Quickdrop — private file sharing",
meta: [
{
name: "description",
content: "Send one end-to-end encrypted file with a private link.",
},
{ name: "referrer", content: "no-referrer" },
{ name: "theme-color", content: "#080d16" },
],
});
</script>
<template>
<NuxtRouteAnnouncer />
<div class="site-shell">
<header class="site-header">
<NuxtLink class="brand" to="/" aria-label="Quickdrop home">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img">
<path d="M12 3v11m0 0 4-4m-4 4-4-4M5 17v2h14v-2" />
</svg>
</span>
<span>Qu<span class="brand-i">ı</span>ckdrop</span>
</NuxtLink>
</header>
<main class="flex-1">
<NuxtPage />
</main>
<footer class="site-footer">
<p>One file. One private link. Authenticated uploads.</p>
<p class="footer-security">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M7 10V7a5 5 0 0 1 10 0v3m-9 0h8a2 2 0 0 1 2 2v7H6v-7a2 2 0 0 1 2-2Z"
/>
</svg>
Your decryption key never leaves your browser.
</p>
</footer>
</div>
</template>

View File

@@ -0,0 +1,26 @@
export function useAuth() {
const authenticated = useState("quickdrop-authenticated", () => false);
const session = useFetch<{ authenticated: boolean }>("/api/auth/session", {
key: "quickdrop-auth-session",
immediate: false,
});
async function refresh() {
try {
await session.execute();
authenticated.value =
!session.error.value && session.data.value?.authenticated === true;
} catch {
authenticated.value = false;
}
}
async function login(passcode: string) {
await $fetch("/api/auth/login", { method: "POST", body: { passcode } });
await refresh();
}
async function logout() {
await $fetch("/api/auth/logout", { method: "POST" });
authenticated.value = false;
}
return { authenticated, refresh, login, logout };
}

View File

@@ -0,0 +1,13 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path.startsWith("/room/")) return;
const auth = useAuth();
await auth.refresh();
if (to.path === "/auth") {
if (auth.authenticated.value) return navigateTo("/");
return;
}
if (!auth.authenticated.value) return navigateTo("/auth");
});

41
app/pages/auth/index.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
const passcode = ref("");
const error = ref("");
const auth = useAuth();
async function submit() {
error.value = "";
try {
await auth.login(passcode.value);
await navigateTo("/");
} catch (cause) {
error.value = cause instanceof Error ? cause.message : "Sign in failed.";
}
}
</script>
<template>
<div class="download-page">
<section class="panel download-card">
<h1>Sign in to Quickdrop.</h1>
<p class="download-intro">
Enter the passcode to upload files to Quickdrop.
</p>
<form @submit.prevent="submit">
<input
v-model="passcode"
type="password"
autocomplete="current-password"
placeholder="••••••••"
class="share-value w-full border border-[var(--border)] rounded p-3"
/>
<button class="button button-large mt-4" type="submit">Continue</button>
<p v-if="error" class="error-message" role="alert">
{{ error }}
</p>
</form>
</section>
</div>
</template>

309
app/pages/index.vue Normal file
View File

@@ -0,0 +1,309 @@
<script setup lang="ts">
import { renderSVG } from "uqr";
import {
CHUNK_SIZE,
DEFAULT_EXPIRY,
DEFAULT_READS,
type ExpiryChoice,
type ReadChoice,
} from "~/../shared/types/drop";
import {
encryptChunk,
encryptManifest,
formatBytes,
generateKey,
generateNoncePrefix,
toBase64Url,
} from "~/utils/crypto";
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const stage = ref<"idle" | "uploading" | "done">("idle");
const errorMessage = ref("");
const shareUrl = ref("");
const progress = ref(0);
const expiresIn = ref<ExpiryChoice>(DEFAULT_EXPIRY);
const maxReads = ref<ReadChoice>(DEFAULT_READS);
const isDragging = ref(false);
const busy = computed(() => stage.value === "uploading");
const qrSvg = computed(() =>
shareUrl.value ? renderSVG(shareUrl.value, { ecc: "M", border: 2 }) : "",
);
const expiryOptions: ExpiryChoice[] = ["1h", "2h", "6h", "12h", "24h", "7d"];
const readOptions = [1, 5, 10, 20, 50, 100] as const;
useSeoMeta({
title: "Send a private file",
description: "Encrypted, resumable file sharing.",
});
function selectFile(file?: File) {
if (!file) return;
selectedFile.value = file;
errorMessage.value = "";
stage.value = "idle";
}
function onDrop(e: DragEvent) {
isDragging.value = false;
const files = e.dataTransfer?.files;
if (files?.length !== 1) {
errorMessage.value = "Please choose exactly one file.";
return;
}
selectFile(files[0]);
}
function onInput(e: Event) {
selectFile((e.target as HTMLInputElement).files?.[0]);
}
async function request(
url: string,
init: RequestInit,
attempts = 5,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch(url, init);
if (
response.ok ||
![408, 425, 429, 500, 502, 503, 504].includes(response.status) ||
attempt >= attempts - 1
)
return response;
} catch (error) {
if (attempt >= attempts - 1) throw error;
}
await new Promise((resolve) =>
setTimeout(
resolve,
Math.round(500 * 2 ** attempt * (0.8 + Math.random() * 0.4)),
),
);
}
}
async function createDrop() {
const file = selectedFile.value;
if (!file || busy.value) return;
stage.value = "uploading";
errorMessage.value = "";
progress.value = 0;
const key = generateKey();
const noncePrefix = generateNoncePrefix();
const chunkCount = Math.max(1, Math.ceil(file.size / CHUNK_SIZE));
try {
const reserve = await request("/api/drops", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: 2,
chunkSize: CHUNK_SIZE,
chunkCount,
noncePrefix,
expiresIn: expiresIn.value,
maxReads: maxReads.value,
}),
});
if (!reserve.ok) throw new Error("Sign in or retry the reservation.");
const reserved = (await reserve.json()) as { id: string; access: string };
for (let index = 0; index < chunkCount; index++) {
const plain = new Uint8Array(
await file
.slice(
index * CHUNK_SIZE,
Math.min(file.size, (index + 1) * CHUNK_SIZE),
)
.arrayBuffer(),
);
const cipher = await encryptChunk(
plain,
key,
reserved.id,
noncePrefix,
index,
CHUNK_SIZE,
chunkCount,
);
const result = await request(
`/api/drops/${reserved.id}/chunks/${index}`,
{
method: "PUT",
headers: { "content-type": "application/octet-stream" },
body: cipher as unknown as BodyInit,
},
);
if (!result.ok)
throw new Error(`Chunk ${index + 1} could not be uploaded.`);
progress.value = ((index + 1) / (chunkCount + 1)) * 100;
}
const manifest = await encryptManifest(
{
version: 2,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
lastModified: file.lastModified,
chunkSize: CHUNK_SIZE,
chunkCount,
},
key,
reserved.id,
noncePrefix,
);
const final = await request(`/api/drops/${reserved.id}/finalize`, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: manifest as unknown as BodyInit,
});
if (!final.ok) throw new Error("The upload could not be finalized.");
shareUrl.value = `${window.location.origin}/room/${reserved.id}#access=${reserved.access}&key=${key}`;
stage.value = "done";
progress.value = 100;
} catch (error) {
stage.value = "idle";
errorMessage.value =
error instanceof Error ? error.message : "Upload failed.";
}
}
function reset() {
selectedFile.value = null;
shareUrl.value = "";
stage.value = "idle";
progress.value = 0;
if (fileInput.value) fileInput.value.value = "";
}
async function copy() {
await navigator.clipboard?.writeText(shareUrl.value);
}
</script>
<template>
<div class="page">
<section v-if="stage !== 'done'" class="hero">
<h1>Send a file.<br />Keep it private.</h1>
<p class="hero-copy">
Files are encrypted in your browser in fixed 8 MiB chunks. Keep this tab
open while uploading.
</p>
</section>
<section v-if="stage !== 'done'" class="rail-layout">
<div class="panel panel-main">
<label
v-if="!selectedFile"
class="drop-zone"
:class="{ 'is-dragging': isDragging }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
><span class="drop-icon"></span>
<h2>Drop your file here</h2>
<p>or choose one from your device · no total file-size limit</p>
<span class="button button-small">Choose a file</span
><input
ref="fileInput"
class="file-input absolute w-px h-px overflow-hidden [clip:rect(0_0_0_0)] [clip-path:inset(50%)] whitespace-nowrap"
type="file"
@change="onInput"
/></label>
<div v-else class="selected-file">
<p class="eyebrow">Ready to encrypt</p>
<div class="selected-card">
<div class="selected-meta">
<p class="selected-name">{{ selectedFile.name }}</p>
<p class="selected-detail">
{{ formatBytes(selectedFile.size) }} ·
{{ selectedFile.type || "Unknown type" }}
</p>
</div>
<button
class="button button-secondary button-small"
:disabled="busy"
@click="reset"
>
Change
</button>
</div>
<div class="grid grid-cols-2 gap-3 mt-5">
<label
>Expires after<select
v-model="expiresIn"
class="w-full p-2 mt-1 bg-[var(--bg)] border border-[var(--border)] rounded"
>
<option
v-for="option in expiryOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
</select></label
><label
>Maximum reads<select
v-model="maxReads"
class="w-full p-2 mt-1 bg-[var(--bg)] border border-[var(--border)] rounded"
>
<option
v-for="option in readOptions"
:key="option"
:value="option"
>
{{ option }}
</option>
<option :value="null">Unlimited</option>
</select></label
>
</div>
<button v-if="!busy" class="button mt-5" @click="createDrop">
Encrypt & upload
</button>
<div v-else class="progress-wrap">
<div class="progress-track">
<div
class="progress-bar"
:style="{ width: `${progress}%`, animation: 'none' }"
/>
</div>
<p class="progress-label">
Uploading encrypted chunks · {{ Math.round(progress) }}%
</p>
</div>
<p class="privacy-note">
The decryption key stays in the URL fragment and never reaches the
server.
</p>
</div>
<p v-if="errorMessage" class="error-message" role="alert">
{{ errorMessage }}
</p>
</div>
<aside class="panel panel-side">
<h2 class="side-heading">Private by default</h2>
<p class="hero-copy">
Shared links are public capabilities. Uploading requires the
configured shared passcode.
</p>
<p class="privacy-note">
Expiry starts only after finalize. A download claim consumes one
selected read.
</p>
</aside>
</section>
<section v-else class="download-page">
<div class="hero">
<p class="eyebrow">Your drop is ready</p>
<h1>Send this private link.</h1>
<p class="hero-copy">
The selected expiry starts at finalize; the selected read policy
applies to explicit downloads.
</p>
</div>
<div class="panel download-card">
<label class="share-label">PRIVATE DOWNLOAD LINK</label
><input class="share-value w-full" readonly :value="shareUrl" /><button
class="button mt-4"
@click="copy"
>
Copy link</button
><button class="button button-secondary mt-4 ml-2" @click="reset">
Send another file
</button>
<div class="qr-frame mt-6" v-html="qrSvg" />
</div>
</section>
</div>
</template>

410
app/pages/room/[id].vue Normal file
View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import {
BLOB_FALLBACK_MAX_BYTES,
BLOB_WARNING_BYTES,
CHUNK_SIZE,
PREVIEW_MAX_BYTES,
type PublicDescriptor,
} from "~/../shared/types/drop";
import {
accessFromHash,
decryptChunk,
decryptManifest,
formatBytes,
fromBase64Url,
keyFromHash,
} from "~/utils/crypto";
import { leaseStorageKey, retryTransient } from "~/utils/download";
const savePicker = () =>
(
window as Window & {
showSaveFilePicker?: (options?: {
suggestedName?: string;
}) => Promise<FileSystemFileHandle>;
}
).showSaveFilePicker;
const route = useRoute();
const state = ref<"loading" | "ready" | "error">("loading");
const errorMessage = ref("");
const manifest = ref<Awaited<ReturnType<typeof decryptManifest>> | null>(null);
const descriptor = ref<PublicDescriptor | null>(null);
const downloading = ref(false);
const previewing = ref(false);
const progress = ref(0);
const hasPicker = ref(false);
const decryptedBlob = ref<Blob | null>(null);
const previewUrl = ref<string | null>(null);
const fallbackUrl = ref<string | null>(null);
const fallbackBlob = ref<Blob | null>(null);
const roomId = computed(() => String(route.params.id || "").toUpperCase());
const canBlob = computed(
() => (manifest.value?.size || 0) <= BLOB_FALLBACK_MAX_BYTES,
);
const previewable = computed(() =>
Boolean(
manifest.value &&
manifest.value.size <= PREVIEW_MAX_BYTES &&
(/^image\/(avif|gif|jpeg|png|webp)$/u.test(manifest.value.type) ||
/^video\/(mp4|webm|ogg)$/u.test(manifest.value.type)),
),
);
useSeoMeta({
title: "Private download",
robots: "noindex, nofollow, noarchive",
});
function tokenKey() {
return leaseStorageKey(roomId.value);
}
function replacePreviewUrl(blob: Blob) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = URL.createObjectURL(blob);
}
function saveBlob(blob: Blob, name: string) {
const picker = savePicker();
if (picker)
return picker({ suggestedName: name }).then(async (handle) => {
const writer = await handle.createWritable();
try {
await writer.write(blob);
await writer.close();
} catch (error) {
await writer.abort().catch(() => undefined);
throw error;
}
});
if (!fallbackUrl.value || fallbackBlob.value !== blob) {
if (fallbackUrl.value) URL.revokeObjectURL(fallbackUrl.value);
fallbackUrl.value = URL.createObjectURL(blob);
fallbackBlob.value = blob;
}
const anchor = document.createElement("a");
anchor.href = fallbackUrl.value;
anchor.download = name;
anchor.rel = "noreferrer";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
async function load() {
try {
const key = keyFromHash(window.location.hash);
const access = accessFromHash(window.location.hash);
if (!key || !access)
throw new Error("This private link is missing its capabilities.");
const response = await fetch(
`/api/drops/${encodeURIComponent(roomId.value)}`,
{ headers: { "x-quickdrop-access": access }, cache: "no-store" },
);
if (!response.ok)
throw new Error(
response.status === 410
? "This drop has expired or has no reads remaining."
: "This drop could not be found.",
);
const data = (await response.json()) as PublicDescriptor;
const encryptedManifest = fromBase64Url(data.manifest);
const decoded = await decryptManifest(
encryptedManifest,
key,
roomId.value,
data.noncePrefix,
data.chunkSize,
data.chunkCount,
);
const expected = data.chunkCipherBytes.reduce(
(sum, bytes) => sum + bytes - 16,
0,
);
if (expected !== decoded.size)
throw new Error("The drop failed its aggregate integrity check.");
descriptor.value = data;
manifest.value = decoded;
state.value = "ready";
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : "This drop could not be opened.";
state.value = "error";
}
}
async function claim(access: string): Promise<string> {
const previous = sessionStorage.getItem(tokenKey()) || undefined;
const response = await retryTransient(() =>
fetch(`/api/drops/${encodeURIComponent(roomId.value)}/leases`, {
method: "POST",
headers: {
"x-quickdrop-access": access,
...(previous ? { authorization: `Bearer ${previous}` } : {}),
},
}),
);
if (!response.ok)
throw new Error(
response.status === 410
? "No download read is available."
: "The download could not be started.",
);
const lease = (await response.json()) as { token: string };
sessionStorage.setItem(tokenKey(), lease.token);
return lease.token;
}
async function complete(token: string) {
const response = await retryTransient(() =>
fetch(`/api/drops/${encodeURIComponent(roomId.value)}/complete`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
}),
);
if (!response.ok)
throw new Error(
"The file was decrypted successfully, but the read could not be recorded. Keep this tab open and retry completion.",
);
sessionStorage.removeItem(tokenKey());
}
async function transferChunks(
token: string,
key: string,
consume: (plain: Uint8Array) => Promise<void> | void,
): Promise<void> {
if (!descriptor.value || !manifest.value)
throw new Error("The drop is not ready.");
const currentDescriptor = descriptor.value;
const expectedSize = manifest.value.size;
let total = 0;
for (let index = 0; index < currentDescriptor.chunkCount; index++) {
const response = await retryTransient(() =>
fetch(`/api/drops/${encodeURIComponent(roomId.value)}/chunks/${index}`, {
headers: { authorization: `Bearer ${token}` },
cache: "no-store",
}),
);
if (!response.ok)
throw new Error(
"A chunk could not be downloaded. Your read is preserved; retry the download.",
);
const cipher = new Uint8Array(await response.arrayBuffer());
const expectedCipherBytes = currentDescriptor.chunkCipherBytes[index];
if (
!Number.isSafeInteger(expectedCipherBytes) ||
cipher.byteLength !== expectedCipherBytes
)
throw new Error("The downloaded chunk has an invalid length.");
const plain = await decryptChunk(
cipher,
key,
roomId.value,
currentDescriptor.noncePrefix,
index,
cipher.byteLength - 16,
CHUNK_SIZE,
currentDescriptor.chunkCount,
);
total += plain.byteLength;
await consume(plain);
progress.value = ((index + 1) / currentDescriptor.chunkCount) * 100;
}
if (total !== expectedSize)
throw new Error(
"The downloaded file failed its size check; your read is preserved.",
);
}
async function decryptAll(token: string, key: string): Promise<Blob> {
if (!manifest.value) throw new Error("The drop is not ready.");
const type = manifest.value.type;
const parts: Uint8Array[] = [];
await transferChunks(token, key, (plain) => {
parts.push(plain);
});
return new Blob(parts as BlobPart[], { type });
}
async function preview() {
if (
!previewable.value ||
!manifest.value ||
downloading.value ||
previewing.value
)
return;
previewing.value = true;
downloading.value = true;
errorMessage.value = "";
progress.value = 0;
try {
const key = keyFromHash(window.location.hash)!;
const access = accessFromHash(window.location.hash)!;
const token = await claim(access);
const blob = await decryptAll(token, key);
decryptedBlob.value = blob;
replacePreviewUrl(blob);
try {
await complete(token);
} catch (error) {
errorMessage.value =
error instanceof Error
? error.message
: "The file was decrypted successfully, but completion could not be recorded.";
}
} catch (error) {
errorMessage.value =
error instanceof Error
? error.message
: "Preview failed integrity checks.";
} finally {
previewing.value = false;
downloading.value = false;
}
}
async function download() {
if (!manifest.value || !descriptor.value || downloading.value) return;
const metadata = manifest.value;
if (decryptedBlob.value) {
downloading.value = true;
try {
await saveBlob(decryptedBlob.value, metadata.name);
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : "The file could not be saved.";
} finally {
downloading.value = false;
}
return;
}
const picker = savePicker();
if (!picker && !canBlob.value) {
errorMessage.value =
"This file is larger than the safe browser fallback. Use a desktop browser with File System Access support.";
return;
}
if (
!picker &&
metadata.size > BLOB_WARNING_BYTES &&
!window.confirm(
"This browser must assemble the file in memory. Continue and consume one read?",
)
)
return;
downloading.value = true;
errorMessage.value = "";
progress.value = 0;
let writer: FileSystemWritableFileStream | null = null;
try {
const key = keyFromHash(window.location.hash)!;
const access = accessFromHash(window.location.hash)!;
// The picker must open before the read is claimed so cancelling it costs no read.
const handle = picker
? await picker({ suggestedName: metadata.name })
: null;
const token = await claim(access);
if (handle) {
writer = await handle.createWritable();
await transferChunks(token, key, (plain) =>
writer!.write(plain as unknown as ArrayBuffer),
);
await writer.close();
writer = null;
} else {
const blob = await decryptAll(token, key);
decryptedBlob.value = blob;
await saveBlob(blob, metadata.name);
}
await complete(token);
} catch (error) {
if (writer) await writer.abort().catch(() => undefined);
errorMessage.value =
error instanceof Error ? error.message : "Download failed.";
} finally {
downloading.value = false;
}
}
onMounted(() => {
hasPicker.value = Boolean(savePicker());
load();
});
onBeforeUnmount(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
if (fallbackUrl.value) URL.revokeObjectURL(fallbackUrl.value);
});
</script>
<template>
<div class="download-page">
<template v-if="state === 'ready' && manifest"
><p class="eyebrow text-center">Private file transfer</p>
<h1 class="download-heading">Ready to download.</h1>
<p class="download-intro">
Opening a download or preview consumes one read. The file is decrypted
one chunk at a time on this device.
</p>
<section class="panel download-card">
<div class="selected-card download-file">
<div class="selected-meta">
<p class="selected-name">{{ manifest.name }}</p>
<p class="selected-detail">
{{ formatBytes(manifest.size) }} · {{ manifest.type }}
</p>
</div>
</div>
<div class="flex gap-2">
<button
v-if="previewable"
class="button button-secondary button-large"
:disabled="downloading"
@click="preview"
>
{{
previewing
? `Previewing… ${Math.round(progress)}%`
: "Preview (consumes one read)"
}}</button
><button
class="button button-large download-button"
:disabled="downloading"
@click="download"
>
{{
downloading
? `Downloading… ${Math.round(progress)}%`
: decryptedBlob
? "Save file"
: "Download file"
}}
</button>
</div>
<div v-if="previewUrl" class="preview">
<img
v-if="manifest.type.startsWith('image/')"
:src="previewUrl"
:alt="manifest.name"
/><video v-else :src="previewUrl" controls preload="metadata" />
</div>
<p v-if="!hasPicker && canBlob" class="privacy-note">
This browser uses a bounded Blob fallback{{
manifest.size > BLOB_WARNING_BYTES
? " and will warn before claiming a read"
: ""
}}.
</p>
<p v-if="!canBlob" class="privacy-note">
Files over 256 MiB require File System Access support; no read will be
claimed here.
</p>
<p v-if="errorMessage" class="error-message" role="alert">
{{ errorMessage }}
</p>
</section></template
><template v-else
><h1 class="download-heading">Opening your drop.</h1>
<section class="panel download-card state-box">
<p v-if="state === 'loading'">Reading the encrypted descriptor</p>
<template v-else
><h2>This drop couldn't be opened.</h2>
<p>{{ errorMessage }}</p>
<NuxtLink class="button button-secondary button-small" to="/"
>Send a new file</NuxtLink
></template
>
</section></template
>
</div>
</template>

6
app/types/file-system-access.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
interface Window {
showSaveFilePicker?: (options?: {
suggestedName?: string;
}) => Promise<FileSystemFileHandle>;
}
export {};

297
app/utils/crypto.ts Normal file
View File

@@ -0,0 +1,297 @@
import {
CHUNK_SIZE,
GCM_TAG_BYTES,
MAX_MANIFEST_PLAINTEXT,
PROTOCOL_VERSION,
} from "~/../shared/types/drop";
import {
decodeBase64Url,
validateRoomId,
} from "~/../shared/utils/drop-contract";
import type { EncryptedManifest } from "~/../shared/types/drop";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const MANIFEST_COUNTER = 0xffffffff;
export function safeFilename(value: string): string {
const cleaned = value
.normalize("NFC")
.replace(/[\\/\u0000-\u001F\u007F\u202A-\u202E\u2066-\u2069]/gu, "_")
.trim();
return [...cleaned].slice(0, 240).join("") || "download";
}
export function toBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(/=+$/u, "");
}
export function fromBase64Url(value: string): Uint8Array {
return decodeBase64Url(value);
}
export function keyFromHash(hash: string): string | null {
return new URLSearchParams(hash.replace(/^#/u, "")).get("key");
}
export function accessFromHash(hash: string): string | null {
return new URLSearchParams(hash.replace(/^#/u, "")).get("access");
}
export function generateKey(): string {
return toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
}
export function generateNoncePrefix(): string {
return toBase64Url(crypto.getRandomValues(new Uint8Array(8)));
}
async function importKey(encoded: string, usage: KeyUsage): Promise<CryptoKey> {
const raw = fromBase64Url(encoded);
if (raw.byteLength !== 32)
throw new Error("The private link contains an invalid key.");
return crypto.subtle.importKey(
"raw",
asBuffer(raw),
{ name: "AES-GCM" },
false,
[usage],
);
}
function iv(prefix: string, counter: number): Uint8Array {
const bytes = fromBase64Url(prefix);
if (bytes.byteLength !== 8) throw new Error("Invalid nonce prefix");
const result = new Uint8Array(12);
result.set(bytes);
new DataView(result.buffer).setUint32(8, counter >>> 0, false);
return result;
}
function aad(
id: string,
kind: 0 | 1,
index: number,
chunkSize: number,
chunkCount: number,
plainLength: number,
): Uint8Array {
validateRoomId(id);
const idBytes = textEncoder.encode(id);
if (idBytes.byteLength !== 32) throw new Error("Invalid drop id");
const result = new Uint8Array(4 + 32 + 1 + 16);
result.set([0x51, 0x44, 0x32, 0], 0);
result.set(idBytes, 4);
result[36] = kind;
const view = new DataView(result.buffer);
view.setUint32(37, index >>> 0, false);
view.setUint32(41, chunkSize >>> 0, false);
view.setUint32(45, chunkCount >>> 0, false);
view.setUint32(49, plainLength >>> 0, false);
return result;
}
function asBuffer(data: Uint8Array): ArrayBuffer {
return data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
}
async function seal(
data: Uint8Array,
encodedKey: string,
id: string,
prefix: string,
kind: 0 | 1,
index: number,
chunkSize: number,
count: number,
): Promise<Uint8Array> {
const key = await importKey(encodedKey, "encrypt");
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: asBuffer(iv(prefix, index)),
additionalData: asBuffer(
aad(id, kind, index, chunkSize, count, data.byteLength),
),
tagLength: 128,
},
key,
asBuffer(data),
);
return new Uint8Array(encrypted);
}
async function open(
data: Uint8Array,
encodedKey: string,
id: string,
prefix: string,
kind: 0 | 1,
index: number,
chunkSize: number,
count: number,
plainLength: number,
): Promise<Uint8Array> {
if (data.byteLength < GCM_TAG_BYTES)
throw new Error("Encrypted data is truncated.");
const key = await importKey(encodedKey, "decrypt");
let decrypted: ArrayBuffer;
try {
decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: asBuffer(iv(prefix, index)),
additionalData: asBuffer(
aad(id, kind, index, chunkSize, count, plainLength),
),
tagLength: 128,
},
key,
asBuffer(data),
);
} catch {
throw new Error("Encrypted data failed integrity verification.");
}
const result = new Uint8Array(decrypted);
if (result.byteLength !== plainLength)
throw new Error("Decrypted length does not match the manifest.");
return result;
}
export async function encryptChunk(
plain: Uint8Array | ArrayBuffer,
key: string,
id: string,
noncePrefix: string,
index: number,
chunkSize = CHUNK_SIZE,
chunkCount = 1,
): Promise<Uint8Array> {
const data = plain instanceof Uint8Array ? plain : new Uint8Array(plain);
return seal(data, key, id, noncePrefix, 1, index, chunkSize, chunkCount);
}
export async function decryptChunk(
cipher: Uint8Array | ArrayBuffer,
key: string,
id: string,
noncePrefix: string,
index: number,
plainLength: number,
chunkSize = CHUNK_SIZE,
chunkCount = 1,
): Promise<Uint8Array> {
const data = cipher instanceof Uint8Array ? cipher : new Uint8Array(cipher);
return open(
data,
key,
id,
noncePrefix,
1,
index,
chunkSize,
chunkCount,
plainLength,
);
}
export async function encryptManifest(
manifest: EncryptedManifest,
key: string,
id: string,
noncePrefix: string,
): Promise<Uint8Array> {
const bytes = textEncoder.encode(JSON.stringify(manifest));
if (bytes.byteLength > MAX_MANIFEST_PLAINTEXT)
throw new Error("Manifest is too large.");
return seal(
bytes,
key,
id,
noncePrefix,
0,
MANIFEST_COUNTER,
manifest.chunkSize,
manifest.chunkCount,
);
}
export async function decryptManifest(
cipher: Uint8Array | ArrayBuffer,
key: string,
id: string,
noncePrefix: string,
chunkSize: number,
chunkCount: number,
): Promise<EncryptedManifest> {
const data = cipher instanceof Uint8Array ? cipher : new Uint8Array(cipher);
if (data.byteLength <= GCM_TAG_BYTES)
throw new Error("The encrypted manifest is empty.");
const bytes = await open(
data,
key,
id,
noncePrefix,
0,
MANIFEST_COUNTER,
chunkSize,
chunkCount,
data.byteLength - GCM_TAG_BYTES,
);
let parsed: unknown;
try {
parsed = JSON.parse(textDecoder.decode(bytes));
} catch {
throw new Error("The encrypted manifest is invalid.");
}
if (!parsed || typeof parsed !== "object")
throw new Error("The encrypted manifest is invalid.");
const value = parsed as Record<string, unknown>;
const fields = [
"version",
"name",
"type",
"size",
"lastModified",
"chunkSize",
"chunkCount",
];
if (
Object.keys(value).some((field) => !fields.includes(field)) ||
Object.keys(value).length !== fields.length ||
value.version !== PROTOCOL_VERSION ||
typeof value.name !== "string" ||
typeof value.type !== "string" ||
!Number.isSafeInteger(value.size) ||
(value.size as number) < 0 ||
!Number.isSafeInteger(value.lastModified) ||
(value.lastModified as number) < 0 ||
value.chunkSize !== chunkSize ||
value.chunkCount !== chunkCount
)
throw new Error("The encrypted manifest is invalid.");
if (value.name.length > 1024 || value.type.length > 255)
throw new Error("The encrypted manifest is invalid.");
return {
version: 2,
name: safeFilename(value.name),
type: value.type || "application/octet-stream",
size: value.size as number,
lastModified: value.lastModified as number,
chunkSize,
chunkCount,
};
}
export interface DropMetadata {
name: string;
type: string;
size: number;
lastModified: number;
}
export interface DecryptedDrop {
blob: Blob;
metadata: DropMetadata;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const unit = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** unit;
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
}

30
app/utils/download.ts Normal file
View File

@@ -0,0 +1,30 @@
export const TRANSIENT_DOWNLOAD_STATUSES = [
408, 425, 429, 500, 502, 503, 504,
] as const;
export function leaseStorageKey(id: string): string {
return `quickdrop-lease:${id}`;
}
export async function retryTransient(
operation: () => Promise<Response>,
attempts = 5,
): Promise<Response> {
for (let attempt = 0; ; attempt++) {
try {
const response = await operation();
if (
response.ok ||
!TRANSIENT_DOWNLOAD_STATUSES.includes(response.status as never) ||
attempt >= attempts - 1
)
return response;
} catch (error) {
if (attempt >= attempts - 1) throw error;
}
await new Promise((resolve) =>
setTimeout(
resolve,
Math.round(500 * 2 ** attempt * (0.8 + Math.random() * 0.4)),
),
);
}
}