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

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Required shared-passcode authentication
AUTH_PASSCODE=replace-with-a-long-random-passcode
AUTH_SECRET=replace-with-at-least-32-random-bytes
AUTH_TOKEN_TTL_DAYS=7
# Persistent local filesystem (one Node/Nitro process only)
QUICKDROP_STORAGE_DIR=.data/quickdrop

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# Quickdrop
**AI-generated content discolsure**
This project is largely AI generated, but based off of my work on [Noctis]{https://gitea.wildcardproject.com/zoeissleeping/noctis}.
Its largely unsloppified currently, but that unsloppiness isnt _too_ horrific like some other projects I've been working on and will need to heavily manually deslop.
My Slop Score: 5/10. It's not bad, but it's not great.
Quickdrop is secure self hosted file drop service. The goal is to take a file
on your PC, upload it via Quickdrop, scane the QR code on your phone or share
the link, and download the file with no additional software or setup and a
zero-trust policy.
## Deployment
Run one Node/Nitro process behind HTTPS and a Cloudflare proxy. Storage is local (`QUICKDROP_STORAGE_DIR`, default `.data/quickdrop`) and locks are in-process, so multiple workers, containers, or replicas are unsupported. Configure `AUTH_PASSCODE`, `AUTH_SECRET` (a long random secret), and optionally `AUTH_TOKEN_TTL_DAYS`; see `.env.example`. Use backups, disk monitoring, and Cloudflare/IP rate limits. Cloudflare request sizing is safe because upload and download requests are one encrypted chunk (about 8 MiB), not whole files.
The uploader and reservation APIs require the shared passcode. Room descriptors, lease claims, encrypted chunk reads, and completion are public capability APIs. A descriptor lookup does not consume a read. Explicit download claims one read; interrupted downloads reuse their active `sessionStorage` lease. Expiry starts only when finalize succeeds. Choices are 1h/2h/6h/12h/24h/7d and 1/5/10/20/50/100/unlimited reads.
## Browser behavior
The File System Access API path writes and decrypts one chunk at a time and supports very large files without whole-file browser materialization. Browsers without it use a deliberately bounded Blob fallback: a warning appears above 100 MiB and files above 256 MiB require a supported desktop browser. Keep the upload tab open and do not share a link until finalize completes.
This service has no application total-file-size quota, but disk capacity and operational rate limits remain important. Existing legacy whole-body room drops are not compatible with protocol v2.

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)),
),
);
}
}

7658
aube-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

27
nuxt.config.ts Normal file
View File

@@ -0,0 +1,27 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: false },
runtimeConfig: {
authPasscode: process.env.AUTH_PASSCODE || "",
authSecret: process.env.AUTH_SECRET || "",
authTokenTtlDays: process.env.AUTH_TOKEN_TTL_DAYS || "7",
storageDir: process.env.QUICKDROP_STORAGE_DIR || ".data/quickdrop",
},
modules: ["@unocss/nuxt"],
app: {
head: {
htmlAttrs: { lang: "en" },
link: [{ rel: "icon", href: "/favicon.ico" }],
},
},
routeRules: {
"/**": {
headers: {
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
},
},
},
});

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "quickdrop",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"typecheck": "nuxt typecheck",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"test": "node --test tests/*.test.mjs",
"format": "prettier --write \"app/**/*.{vue,ts}\" \"server/**/*.ts\" \"shared/**/*.ts\" \"tests/**/*.mjs\" \"*.{ts,json,md}\"",
"format:check": "prettier --check \"app/**/*.{vue,ts}\" \"server/**/*.ts\" \"shared/**/*.ts\" \"tests/**/*.mjs\" \"*.{ts,json,md}\"",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@unocss/nuxt": "66.7.5",
"nuxt": "^4.5.2",
"uqr": "0.1.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"prettier": "3.8.1",
"typescript": "^5.9.3",
"vue-tsc": "^3.2.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
User-agent: *
Disallow: /room/
Disallow: /api/

View File

@@ -0,0 +1,35 @@
import { createError, defineEventHandler, setResponseStatus } from "h3";
import {
authConfigured,
issueSession,
requireSameOrigin,
verifyPasscode,
} from "../../utils/auth";
import { readJson } from "../../utils/http";
export default defineEventHandler(async (event) => {
requireSameOrigin(event);
const body = await readJson<{ passcode?: unknown }>(event, 1024);
if (
!body ||
typeof body !== "object" ||
Object.keys(body).some((key) => key !== "passcode")
)
throw createError({
statusCode: 400,
statusMessage: "Malformed login request.",
});
if (!authConfigured())
throw createError({
statusCode: 503,
statusMessage: "Authentication is not configured.",
});
if (!verifyPasscode(body?.passcode)) {
await new Promise((resolve) => setTimeout(resolve, 350));
throw createError({
statusCode: 401,
statusMessage: "Incorrect passcode.",
});
}
issueSession(event);
setResponseStatus(event, 204);
});

View File

@@ -0,0 +1,7 @@
import { defineEventHandler, setResponseStatus } from "h3";
import { clearAuthCookie, requireSameOrigin } from "../../utils/auth";
export default defineEventHandler((event) => {
requireSameOrigin(event);
clearAuthCookie(event);
setResponseStatus(event, 204);
});

View File

@@ -0,0 +1,5 @@
import { defineEventHandler } from "h3";
import { isAuthenticated } from "../../utils/auth";
export default defineEventHandler((event) => ({
authenticated: isAuthenticated(event),
}));

View File

@@ -0,0 +1,25 @@
import {
createError,
defineEventHandler,
getRequestHeader,
getRouterParam,
} from "h3";
import { dropStore, DropStoreError } from "../../utils/drop-store";
import { publicHeaders } from "../../utils/http";
export default defineEventHandler(async (event) => {
publicHeaders(event);
const id = String(getRouterParam(event, "id") || "").toUpperCase();
const access = getRequestHeader(event, "x-quickdrop-access") || "";
try {
return await dropStore.descriptor(id, access);
} catch (error) {
if (error instanceof DropStoreError && error.code === "expired")
throw createError({ statusCode: 410, statusMessage: "Drop expired." });
if (error instanceof DropStoreError && error.code === "forbidden")
throw createError({
statusCode: 403,
statusMessage: "Invalid access capability.",
});
throw createError({ statusCode: 404, statusMessage: "Drop not found." });
}
});

View File

@@ -0,0 +1,72 @@
import {
createError,
defineEventHandler,
getRouterParam,
sendStream,
setHeaders,
} from "h3";
import { dropStore, DropStoreError } from "../../../../utils/drop-store";
import { bearerToken, publicHeaders } from "../../../../utils/http";
export default defineEventHandler(async (event) => {
publicHeaders(event);
const token = bearerToken(event);
if (!token)
throw createError({
statusCode: 403,
statusMessage: "A valid lease is required.",
});
const id = String(getRouterParam(event, "id") || "").toUpperCase();
let handle:
| Awaited<ReturnType<typeof dropStore.readChunk>>["handle"]
| undefined;
let stream:
| ReturnType<NonNullable<typeof handle>["createReadStream"]>
| undefined;
let closed = false;
const close = async () => {
if (closed) return;
closed = true;
stream?.destroy();
await handle?.close().catch(() => undefined);
};
const abort = () => {
if (!event.node.res.writableEnded && stream && !stream.destroyed)
stream.destroy(new Error("Client disconnected."));
void close();
};
try {
const chunk = await dropStore.readChunk(
id,
Number(getRouterParam(event, "index")),
token,
);
handle = chunk.handle;
stream = handle.createReadStream();
event.node.res.once("close", abort);
event.node.res.once("finish", close);
setHeaders(event, {
"content-type": "application/octet-stream",
"content-length": chunk.size,
"cache-control": "private, no-store",
});
return await sendStream(event, stream);
} catch (error) {
await close();
if (error instanceof DropStoreError)
throw createError({
statusCode:
error.code === "expired"
? 410
: error.code === "not-found"
? 404
: 403,
statusMessage: error.message,
});
throw error;
} finally {
event.node.res.off("close", abort);
event.node.res.off("finish", close);
await close();
}
});

View File

@@ -0,0 +1,30 @@
import {
createError,
defineEventHandler,
getRouterParam,
setResponseStatus,
} from "h3";
import { requireAuth } from "../../../../utils/auth";
import { dropStore, DropStoreError } from "../../../../utils/drop-store";
import { readBounded, requireContentType } from "../../../../utils/http";
export default defineEventHandler(async (event) => {
requireAuth(event);
requireContentType(event, "application/octet-stream");
const id = String(getRouterParam(event, "id") || "").toUpperCase();
const index = Number(getRouterParam(event, "index"));
try {
const result = await dropStore.putChunk(
id,
index,
await readBounded(event, 8388624),
);
setResponseStatus(event, result === "created" ? 201 : 204);
} catch (error) {
if (error instanceof DropStoreError) {
const status =
error.code === "not-found" ? 404 : error.code === "expired" ? 410 : 409;
throw createError({ statusCode: status, statusMessage: error.message });
}
throw error;
}
});

View File

@@ -0,0 +1,32 @@
import {
createError,
defineEventHandler,
getRouterParam,
setResponseStatus,
} from "h3";
import { dropStore, DropStoreError } from "../../../utils/drop-store";
import { bearerToken, publicHeaders, readBounded } from "../../../utils/http";
export default defineEventHandler(async (event) => {
publicHeaders(event);
if ((await readBounded(event, 0)).byteLength)
throw createError({
statusCode: 400,
statusMessage: "Completion body must be empty.",
});
const token = bearerToken(event);
if (!token)
throw createError({
statusCode: 403,
statusMessage: "A valid lease is required.",
});
try {
await dropStore.complete(
String(getRouterParam(event, "id") || "").toUpperCase(),
token,
);
} catch (error) {
if (!(error instanceof DropStoreError))
throw error; /* Completion is deliberately idempotent: an already completed, expired, or unknown lease has the desired state. */
}
setResponseStatus(event, 204);
});

View File

@@ -0,0 +1,37 @@
import { createError, defineEventHandler, getRouterParam } from "h3";
import { requireAuth } from "../../../utils/auth";
import { dropStore, DropStoreError } from "../../../utils/drop-store";
import { readBounded, requireContentType } from "../../../utils/http";
import { validateManifestBodyLength } from "../../../../shared/utils/drop-contract";
export default defineEventHandler(async (event) => {
requireAuth(event);
requireContentType(event, "application/octet-stream");
const body = await readBounded(event, 65552);
try {
validateManifestBodyLength(body.byteLength);
} catch {
throw createError({ statusCode: 400, statusMessage: "Invalid manifest." });
}
const id = String(getRouterParam(event, "id") || "").toUpperCase();
try {
const result = await dropStore.finalize(id, body);
return {
id,
expiresAt: new Date(result.expiresAt!).toISOString(),
maxReads: result.maxReads,
};
} catch (error) {
if (error instanceof DropStoreError) {
const status =
error.code === "not-found"
? 404
: error.code === "expired"
? 410
: error.code === "incomplete"
? 409
: 409;
throw createError({ statusCode: status, statusMessage: error.message });
}
throw error;
}
});

View File

@@ -0,0 +1,39 @@
import {
createError,
defineEventHandler,
getRequestHeader,
getRouterParam,
setResponseStatus,
} from "h3";
import { dropStore, DropStoreError } from "../../../utils/drop-store";
import { bearerToken, publicHeaders, readBounded } from "../../../utils/http";
export default defineEventHandler(async (event) => {
publicHeaders(event);
if ((await readBounded(event, 0)).byteLength)
throw createError({
statusCode: 400,
statusMessage: "Lease claim body must be empty.",
});
const id = String(getRouterParam(event, "id") || "").toUpperCase();
const access = getRequestHeader(event, "x-quickdrop-access") || "";
try {
const result = await dropStore.claim(id, access, bearerToken(event));
setResponseStatus(event, result.reused ? 200 : 201);
return {
token: result.token,
leaseExpiresAt: new Date(result.leaseExpiresAt).toISOString(),
expiresAt: new Date(result.expiresAt).toISOString(),
};
} catch (error) {
if (error instanceof DropStoreError) {
const status =
error.code === "exhausted" || error.code === "expired"
? 410
: error.code === "forbidden"
? 403
: 404;
throw createError({ statusCode: status, statusMessage: error.message });
}
throw error;
}
});

View File

@@ -0,0 +1,26 @@
import { createError, defineEventHandler, setResponseStatus } from "h3";
import { requireAuth } from "../../utils/auth";
import { dropStore } from "../../utils/drop-store";
import { readJson } from "../../utils/http";
import { validateReservation } from "../../../shared/utils/drop-contract";
export default defineEventHandler(async (event) => {
requireAuth(event);
const body = readJson(event, 4096);
let request;
try {
request = validateReservation(await body);
} catch (error) {
throw createError({
statusCode: 400,
statusMessage:
error instanceof Error ? error.message : "Invalid reservation.",
});
}
const result = await dropStore.reserve(request);
setResponseStatus(event, 201);
return {
id: result.id,
uploadExpiresAt: new Date(result.uploadExpiresAt).toISOString(),
access: result.access,
};
});

View File

@@ -0,0 +1,16 @@
import { cleanupExpiredDrops } from "../utils/drop-store";
export default defineNitroPlugin((nitroApp) => {
cleanupExpiredDrops().catch((error) =>
console.error("[quickdrop] Initial expiry cleanup failed.", error),
);
const timer = setInterval(() => {
cleanupExpiredDrops().catch((error) =>
console.error("[quickdrop] Scheduled expiry cleanup failed.", error),
);
}, 60 * 1000);
timer.unref();
nitroApp.hooks.hook("close", () => clearInterval(timer));
});

104
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,104 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { H3Event } from "h3";
import {
createError,
deleteCookie,
getCookie,
getRequestHeader,
setCookie,
} from "h3";
const COOKIE = "quickdrop_auth";
const DAY = 86400000;
function config() {
const runtime = useRuntimeConfig();
const configuredTtl = Number(
process.env.AUTH_TOKEN_TTL_DAYS || runtime.authTokenTtlDays || 7,
);
const ttlDays =
Number.isFinite(configuredTtl) && configuredTtl > 0 ? configuredTtl : 7;
return {
passcode: String(process.env.AUTH_PASSCODE || runtime.authPasscode || ""),
secret: String(process.env.AUTH_SECRET || runtime.authSecret || ""),
ttl: ttlDays * DAY,
};
}
function mac(value: string, secret: string) {
return createHmac("sha256", secret).update(value).digest("base64url");
}
export function authConfigured() {
const c = config();
return Boolean(c.passcode && c.secret);
}
export function issueSession(event: H3Event) {
const c = config();
if (!c.secret) throw new Error("Authentication is not configured.");
const payload = `${Date.now() + c.ttl}.${randomBytes(24).toString("base64url")}`;
setCookie(event, COOKIE, `${payload}.${mac(payload, c.secret)}`, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: Math.floor(c.ttl / 1000),
});
}
export function clearAuthCookie(event: H3Event) {
deleteCookie(event, COOKIE, { path: "/" });
}
export function isAuthenticated(event: H3Event): boolean {
const c = config();
const token = getCookie(event, COOKIE);
if (!token || !c.secret) return false;
const split = token.lastIndexOf(".");
if (split < 1) return false;
const payload = token.slice(0, split);
const supplied = Buffer.from(token.slice(split + 1));
const expected = Buffer.from(mac(payload, c.secret));
if (
supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)
)
return false;
const expiry = Number(payload.slice(0, payload.indexOf(".")));
return Number.isSafeInteger(expiry) && expiry > Date.now();
}
export function requireAuth(event: H3Event) {
if (!isAuthenticated(event))
throw createError({
statusCode: 401,
statusMessage: "Authentication required.",
});
requireSameOrigin(event);
}
export function verifyPasscode(value: unknown): boolean {
const c = config();
if (!c.passcode || typeof value !== "string") return false;
const supplied = Buffer.from(value);
const expected = Buffer.from(c.passcode);
return (
supplied.length === expected.length && timingSafeEqual(supplied, expected)
);
}
export function requireSameOrigin(event: H3Event) {
const origin = getRequestHeader(event, "origin");
const site = getRequestHeader(event, "sec-fetch-site");
if (
site &&
["cross-site", "same-site"].includes(site) &&
site === "cross-site"
)
throw createError({
statusCode: 403,
statusMessage: "Cross-site state change rejected.",
});
if (origin) {
const host = getRequestHeader(event, "host");
const proto = getRequestHeader(event, "x-forwarded-proto") || "http";
try {
if (new URL(origin).host !== host || !["http", "https"].includes(proto))
throw new Error();
} catch {
throw createError({ statusCode: 403, statusMessage: "Origin rejected." });
}
}
}
export { COOKIE };

580
server/utils/drop-store.ts Normal file
View File

@@ -0,0 +1,580 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
mkdir,
readFile,
readdir,
rename,
rm,
stat,
writeFile,
open as openFile,
} from "node:fs/promises";
import { resolve } from "node:path";
import {
ROOM_ID_PATTERN,
expiresInMs,
validateReservation,
validateRoomId,
} from "../../shared/utils/drop-contract";
import {
CHUNK_SIZE,
LEASE_IDLE_MS,
UPLOAD_RESERVATION_MS,
type PublicDescriptor,
type ReserveRequest,
type StoredDrop,
type StoredLease,
} from "../../shared/types/drop";
const ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const productionRoot = resolve(
process.env.QUICKDROP_STORAGE_DIR || ".data/quickdrop",
);
export class DropStoreError extends Error {
constructor(
public code:
| "not-found"
| "expired"
| "conflict"
| "incomplete"
| "exhausted"
| "forbidden",
message?: string,
) {
super(message || code);
}
}
interface LockEntry {
tail: Promise<void>;
waiters: number;
}
const locks = new Map<string, LockEntry>();
async function withLock<T>(
key: string,
operation: () => Promise<T>,
): Promise<T> {
let entry = locks.get(key);
if (!entry) {
entry = { tail: Promise.resolve(), waiters: 0 };
locks.set(key, entry);
}
entry.waiters++;
const previous = entry.tail;
let release!: () => void;
entry.tail = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
entry.waiters--;
if (entry.waiters === 0 && locks.get(key) === entry) locks.delete(key);
}
}
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function sameHash(left: string, right: string): boolean {
const a = Buffer.from(left, "hex");
const b = Buffer.from(right, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
function bearer(): string {
return randomBytes(32).toString("base64url");
}
function nowValue(clock: (() => number) | { now(): number }): number {
return typeof clock === "function" ? clock() : clock.now();
}
function ensureId(id: string) {
try {
validateRoomId(id);
} catch {
throw new DropStoreError("not-found", "Drop not found.");
}
return id;
}
export interface DropStoreOptions {
root?: string;
clock?: (() => number) | { now(): number };
}
const TEMP_FILE_GRACE_MS = 60 * 60 * 1000;
const METADATALESS_GRACE_MS = 60 * 60 * 1000;
export function pathsFor(id: string, root = productionRoot) {
ensureId(id);
const directory = resolve(root, id);
return {
directory,
metadata: resolve(directory, "metadata.json"),
manifest: resolve(directory, "manifest.bin"),
chunks: resolve(directory, "chunks"),
};
}
async function atomicWrite(path: string, data: Uint8Array | string) {
const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
await writeFile(temp, data, { mode: 0o600 });
try {
const handle = await openFile(temp, "r");
await handle.sync();
await handle.close();
} catch {
/* fsync is best effort on unusual filesystems */
}
await rename(temp, path);
}
function chunkPath(paths: ReturnType<typeof pathsFor>, index: number) {
return resolve(paths.chunks, `${String(index).padStart(10, "0")}.bin`);
}
export class DropStore {
readonly root: string;
readonly clock: (() => number) | { now(): number };
constructor(options: DropStoreOptions = {}) {
this.root = resolve(options.root || productionRoot);
this.clock = options.clock || (() => Date.now());
}
async ensure() {
await mkdir(this.root, { recursive: true, mode: 0o700 });
}
async reserve(
input: ReserveRequest,
): Promise<{ id: string; uploadExpiresAt: number; access: string }> {
validateReservation(input);
await this.ensure();
for (let attempt = 0; attempt < 100; attempt++) {
const id = Array.from(
randomBytes(32),
(byte) => ALPHABET[byte % ALPHABET.length],
).join("");
const paths = pathsFor(id, this.root);
try {
await mkdir(paths.directory, { recursive: false, mode: 0o700 });
await mkdir(paths.chunks, { mode: 0o700 });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
throw error;
}
const createdAt = nowValue(this.clock);
const access = bearer();
const metadata: StoredDrop = {
version: 2,
id,
state: "uploading",
createdAt,
uploadExpiresAt: createdAt + UPLOAD_RESERVATION_MS,
expiresInMs: expiresInMs(input.expiresIn),
expiresAt: null,
maxReads: input.maxReads,
readsClaimed: 0,
chunkSize: input.chunkSize,
chunkCount: input.chunkCount,
noncePrefix: input.noncePrefix,
leases: {},
accessHash: hash(access),
};
try {
await atomicWrite(paths.metadata, JSON.stringify(metadata));
return { id, uploadExpiresAt: metadata.uploadExpiresAt, access };
} catch (error) {
await rm(paths.directory, { recursive: true, force: true });
throw error;
}
}
throw new Error("Could not allocate a drop id.");
}
async readMetadata(id: string): Promise<StoredDrop | null> {
const paths = pathsFor(id, this.root);
try {
return JSON.parse(await readFile(paths.metadata, "utf8")) as StoredDrop;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
private async metadataLocked(id: string): Promise<StoredDrop> {
const metadata = await this.readMetadata(id);
if (!metadata || metadata.version !== 2 || metadata.id !== id)
throw new DropStoreError("not-found", "Drop not found.");
const now = nowValue(this.clock);
if (
(metadata.state === "uploading" && metadata.uploadExpiresAt <= now) ||
(metadata.state === "ready" &&
metadata.expiresAt !== null &&
metadata.expiresAt <= now)
) {
await rm(pathsFor(id, this.root).directory, {
recursive: true,
force: true,
});
throw new DropStoreError("expired", "Drop expired.");
}
return metadata;
}
private async save(id: string, metadata: StoredDrop) {
await atomicWrite(
pathsFor(id, this.root).metadata,
JSON.stringify(metadata),
);
}
async putChunk(
id: string,
index: number,
body: Uint8Array,
): Promise<"created" | "same"> {
ensureId(id);
return withLock(`${this.root}\0${id}`, async () => {
const metadata = await this.metadataLocked(id);
if (metadata.state !== "uploading")
throw new DropStoreError("conflict", "Drop is already finalized.");
if (
!Number.isSafeInteger(index) ||
index < 0 ||
index >= metadata.chunkCount
)
throw new DropStoreError("conflict", "Invalid chunk index.");
const paths = pathsFor(id, this.root);
const target = chunkPath(paths, index);
const expectedFull = metadata.chunkSize + 16;
if (
body.byteLength < 16 ||
body.byteLength > expectedFull ||
(index < metadata.chunkCount - 1 && body.byteLength !== expectedFull)
)
throw new DropStoreError("conflict", "Invalid chunk length.");
try {
const existing = await readFile(target);
if (
existing.byteLength === body.byteLength &&
sameHash(
hash(existing.toString("base64")),
hash(Buffer.from(body).toString("base64")),
)
)
return "same";
throw new DropStoreError(
"conflict",
"Chunk already exists with different contents.",
);
} catch (error) {
if (error instanceof DropStoreError) throw error;
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const temp = resolve(
paths.directory,
`.chunk-${index}-${randomBytes(5).toString("hex")}`,
);
await writeFile(temp, body, { mode: 0o600 });
try {
const handle = await openFile(temp, "r");
await handle.sync();
await handle.close();
} catch {}
await rename(temp, target);
return "created";
});
}
async finalize(id: string, manifest: Uint8Array): Promise<StoredDrop> {
ensureId(id);
if (manifest.byteLength <= 16)
throw new DropStoreError("conflict", "Invalid manifest.");
return withLock(`${this.root}\0${id}`, async () => {
const metadata = await this.metadataLocked(id);
const paths = pathsFor(id, this.root);
if (metadata.state === "ready") {
const current = await readFile(paths.manifest);
if (Buffer.compare(current, Buffer.from(manifest)) !== 0)
throw new DropStoreError(
"conflict",
"Manifest conflicts with finalized drop.",
);
return metadata;
}
for (let index = 0; index < metadata.chunkCount; index++) {
let info;
try {
info = await stat(chunkPath(paths, index));
} catch {
throw new DropStoreError("incomplete", "Some chunks are missing.");
}
if (
info.size < 16 ||
(index < metadata.chunkCount - 1 &&
info.size !== metadata.chunkSize + 16)
)
throw new DropStoreError("incomplete", "Chunk geometry is invalid.");
}
let previous: Buffer | null = null;
try {
previous = await readFile(paths.manifest);
} catch {}
if (previous && Buffer.compare(previous, Buffer.from(manifest)) !== 0)
throw new DropStoreError(
"conflict",
"Manifest conflicts with staged data.",
);
if (!previous) await atomicWrite(paths.manifest, manifest);
metadata.state = "ready";
metadata.expiresAt = nowValue(this.clock) + metadata.expiresInMs;
metadata.manifestBytes = manifest.byteLength;
metadata.chunkCipherBytes = [];
for (let index = 0; index < metadata.chunkCount; index++)
metadata.chunkCipherBytes.push(
(await stat(chunkPath(paths, index))).size,
);
await this.save(id, metadata);
return metadata;
});
}
async descriptor(id: string, access: string): Promise<PublicDescriptor> {
ensureId(id);
return withLock(`${this.root}\0${id}`, async () => {
const metadata = await this.metadataLocked(id);
if (metadata.state !== "ready")
throw new DropStoreError("not-found", "Drop is not ready.");
if (!sameHash(hash(access), metadata.accessHash))
throw new DropStoreError("forbidden", "Invalid access capability.");
const manifest = await readFile(pathsFor(id, this.root).manifest);
return {
version: 2,
id,
chunkSize: metadata.chunkSize,
chunkCount: metadata.chunkCount,
noncePrefix: metadata.noncePrefix,
chunkCipherBytes: metadata.chunkCipherBytes || [],
manifest: manifest.toString("base64url"),
expiresAt: new Date(metadata.expiresAt!).toISOString(),
canClaim:
metadata.maxReads === null ||
metadata.readsClaimed < metadata.maxReads,
maxReads: metadata.maxReads,
};
});
}
async claim(
id: string,
access: string,
existing?: string,
): Promise<{
token: string;
leaseExpiresAt: number;
expiresAt: number;
reused: boolean;
}> {
ensureId(id);
return withLock(`${this.root}\0${id}`, async () => {
const metadata = await this.metadataLocked(id);
if (metadata.state !== "ready")
throw new DropStoreError("not-found", "Drop is not ready.");
if (!sameHash(hash(access), metadata.accessHash))
throw new DropStoreError("forbidden", "Invalid access capability.");
const now = nowValue(this.clock);
for (const [tokenHash, lease] of Object.entries(metadata.leases)) {
if (lease.expiresAt <= now) delete metadata.leases[tokenHash];
}
if (existing && metadata.leases[hash(existing)]) {
const lease = metadata.leases[hash(existing)]!;
lease.lastSeenAt = now;
lease.expiresAt = Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!);
await this.save(id, metadata);
return {
token: existing,
leaseExpiresAt: lease.expiresAt,
expiresAt: metadata.expiresAt!,
reused: true,
};
}
if (
metadata.maxReads !== null &&
metadata.readsClaimed >= metadata.maxReads
) {
await this.save(id, metadata);
throw new DropStoreError("exhausted", "No new reads remain.");
}
const token = bearer();
const lease: StoredLease = {
createdAt: now,
lastSeenAt: now,
expiresAt: Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!),
};
metadata.leases[hash(token)] = lease;
metadata.readsClaimed++;
await this.save(id, metadata);
return {
token,
leaseExpiresAt: lease.expiresAt,
expiresAt: metadata.expiresAt!,
reused: false,
};
});
}
async readChunk(
id: string,
index: number,
token: string,
): Promise<{ handle: Awaited<ReturnType<typeof openFile>>; size: number }> {
ensureId(id);
return withLock(`${this.root}\0${id}`, async () => {
const metadata = await this.metadataLocked(id);
const tokenHash = hash(token);
if (metadata.state !== "ready" || !metadata.leases[tokenHash])
throw new DropStoreError("forbidden", "Invalid lease.");
const lease = metadata.leases[tokenHash]!;
const now = nowValue(this.clock);
if (lease.expiresAt <= now) {
delete metadata.leases[hash(token)];
await this.save(id, metadata);
throw new DropStoreError("forbidden", "Lease expired.");
}
if (
!Number.isSafeInteger(index) ||
index < 0 ||
index >= metadata.chunkCount
)
throw new DropStoreError("not-found", "Chunk not found.");
lease.lastSeenAt = now;
lease.expiresAt = Math.min(now + LEASE_IDLE_MS, metadata.expiresAt!);
await this.save(id, metadata);
const handle = await openFile(
chunkPath(pathsFor(id, this.root), index),
"r",
);
try {
const info = await handle.stat();
return { handle, size: info.size };
} catch (error) {
await handle.close().catch(() => undefined);
throw error;
}
});
}
async complete(id: string, token: string): Promise<void> {
ensureId(id);
return withLock(`${this.root}\0${id}`, async () => {
const existing = await this.readMetadata(id);
if (!existing) return;
const now = nowValue(this.clock);
if (existing.expiresAt !== null && existing.expiresAt <= now) {
await rm(pathsFor(id, this.root).directory, {
recursive: true,
force: true,
});
return;
}
const metadata = await this.metadataLocked(id);
const key = hash(token);
if (!metadata.leases[key])
throw new DropStoreError("forbidden", "Invalid lease.");
delete metadata.leases[key];
if (
metadata.maxReads !== null &&
metadata.readsClaimed >= metadata.maxReads &&
Object.keys(metadata.leases).length === 0
)
await rm(pathsFor(id, this.root).directory, {
recursive: true,
force: true,
});
else await this.save(id, metadata);
});
}
async delete(id: string) {
ensureId(id);
await withLock(`${this.root}\0${id}`, () =>
rm(pathsFor(id, this.root).directory, { recursive: true, force: true }),
);
}
async cleanup() {
await this.ensure();
const entries = await readdir(this.root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !ROOM_ID_PATTERN.test(entry.name)) continue;
await withLock(`${this.root}\0${entry.name}`, async () => {
try {
const dropDirectory = pathsFor(entry.name, this.root).directory;
const now = nowValue(this.clock);
for (const temporary of await readdir(dropDirectory)) {
if (
!temporary.startsWith(".chunk-") &&
!temporary.includes(".tmp-")
)
continue;
const temporaryPath = resolve(dropDirectory, temporary);
const temporaryInfo = await stat(temporaryPath).catch(() => null);
if (
temporaryInfo &&
now - temporaryInfo.mtimeMs >= TEMP_FILE_GRACE_MS
)
await rm(temporaryPath, { force: true });
}
const metadata = await this.readMetadata(entry.name);
if (!metadata) {
const directoryInfo = await stat(dropDirectory);
if (now - directoryInfo.mtimeMs >= METADATALESS_GRACE_MS)
await rm(dropDirectory, { recursive: true, force: true });
return;
}
if (
(metadata.state === "uploading" &&
metadata.uploadExpiresAt <= now) ||
(metadata.state === "ready" &&
metadata.expiresAt !== null &&
metadata.expiresAt <= now)
) {
await rm(dropDirectory, { recursive: true, force: true });
return;
}
let changed = false;
for (const [key, lease] of Object.entries(metadata.leases))
if (
lease.expiresAt <= now ||
now - lease.lastSeenAt >= LEASE_IDLE_MS
) {
delete metadata.leases[key];
changed = true;
}
if (
metadata.maxReads !== null &&
metadata.readsClaimed >= metadata.maxReads &&
Object.keys(metadata.leases).length === 0
) {
await rm(dropDirectory, { recursive: true, force: true });
return;
}
if (changed) await this.save(entry.name, metadata);
} catch (error) {
console.error(
`[quickdrop] ignoring malformed drop ${entry.name}`,
error,
);
await rm(resolve(this.root, entry.name), {
recursive: true,
force: true,
});
}
});
}
}
}
export const dropStore = new DropStore();
export async function ensureStore() {
return dropStore.ensure();
}
export async function cleanupExpiredDrops() {
return dropStore.cleanup();
}
export async function deleteDrop(id: string) {
return dropStore.delete(id);
}
export async function createRoomId() {
return (
await dropStore.reserve({
version: 2,
chunkSize: CHUNK_SIZE,
chunkCount: 1,
noncePrefix: randomBytes(8).toString("base64url"),
expiresIn: "24h",
maxReads: 1,
})
).id;
}

55
server/utils/http.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { H3Event } from "h3";
import { createError, getRequestHeader } from "h3";
export async function readBounded(
event: H3Event,
max: number,
): Promise<Buffer> {
const declared = getRequestHeader(event, "content-length");
if (declared && (!/^\d+$/u.test(declared) || Number(declared) > max))
throw createError({
statusCode: 413,
statusMessage: "Request body exceeds the limit.",
});
const chunks: Buffer[] = [];
let total = 0;
for await (const value of event.node.req) {
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
total += chunk.byteLength;
if (total > max)
throw createError({
statusCode: 413,
statusMessage: "Request body exceeds the limit.",
});
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
export async function readJson<T>(event: H3Event, max: number): Promise<T> {
const body = await readBounded(event, max);
try {
return JSON.parse(body.toString("utf8")) as T;
} catch {
throw createError({ statusCode: 400, statusMessage: "Malformed JSON." });
}
}
export function requireContentType(event: H3Event, expected: string) {
const type = (
(getRequestHeader(event, "content-type") || "").split(";", 1)[0] || ""
)
.trim()
.toLowerCase();
if (type !== expected)
throw createError({
statusCode: 415,
statusMessage: `Content-Type must be ${expected}.`,
});
}
export function bearerToken(event: H3Event): string | undefined {
const value = getRequestHeader(event, "authorization") || "";
return /^Bearer ([A-Za-z0-9_-]{20,})$/u.exec(value)?.[1];
}
export function publicHeaders(event: H3Event) {
event.node.res.setHeader("cache-control", "no-store");
event.node.res.setHeader("referrer-policy", "no-referrer");
event.node.res.setHeader("x-content-type-options", "nosniff");
}

79
shared/types/drop.ts Normal file
View File

@@ -0,0 +1,79 @@
export const PROTOCOL_VERSION = 2 as const;
export const CHUNK_SIZE = 8 * 1024 * 1024;
export const GCM_TAG_BYTES = 16;
export const MAX_CHUNK_BODY = CHUNK_SIZE + GCM_TAG_BYTES;
export const MAX_MANIFEST_PLAINTEXT = 64 * 1024;
export const MAX_MANIFEST_BODY = MAX_MANIFEST_PLAINTEXT + GCM_TAG_BYTES;
export const UPLOAD_RESERVATION_MS = 24 * 60 * 60 * 1000;
export const LEASE_IDLE_MS = 15 * 60 * 1000;
export const BLOB_WARNING_BYTES = 100 * 1024 * 1024;
export const BLOB_FALLBACK_MAX_BYTES = 256 * 1024 * 1024;
export const PREVIEW_MAX_BYTES = 25 * 1024 * 1024;
export const EXPIRY_VALUES = ["1h", "2h", "6h", "12h", "24h", "7d"] as const;
export type ExpiryChoice = (typeof EXPIRY_VALUES)[number];
export const READ_VALUES = [1, 5, 10, 20, 50, 100] as const;
export type ReadChoice = (typeof READ_VALUES)[number] | null;
export const DEFAULT_EXPIRY: ExpiryChoice = "24h";
export const DEFAULT_READS: ReadChoice = 1;
export type DropState = "uploading" | "ready";
export interface ReserveRequest {
version: 2;
chunkSize: number;
chunkCount: number;
noncePrefix: string;
expiresIn: ExpiryChoice;
maxReads: ReadChoice;
}
export interface ReserveResponse {
id: string;
uploadExpiresAt: string;
}
export interface PublicDescriptor {
version: 2;
id: string;
chunkSize: number;
chunkCount: number;
noncePrefix: string;
chunkCipherBytes: number[];
manifest: string;
expiresAt: string;
canClaim: boolean;
maxReads: ReadChoice;
}
export interface EncryptedManifest {
version: 2;
name: string;
type: string;
size: number;
lastModified: number;
chunkSize: number;
chunkCount: number;
}
export interface LeaseResponse {
token: string;
leaseExpiresAt: string;
expiresAt: string;
}
export interface StoredLease {
createdAt: number;
lastSeenAt: number;
expiresAt: number;
}
export interface StoredDrop {
version: 2;
id: string;
state: DropState;
createdAt: number;
uploadExpiresAt: number;
expiresInMs: number;
expiresAt: number | null;
maxReads: ReadChoice;
readsClaimed: number;
chunkSize: number;
chunkCount: number;
noncePrefix: string;
manifestBytes?: number;
chunkCipherBytes?: number[];
leases: Record<string, StoredLease>;
accessHash: string;
}

View File

@@ -0,0 +1,121 @@
import {
CHUNK_SIZE,
EXPIRY_VALUES,
GCM_TAG_BYTES,
MAX_CHUNK_BODY,
MAX_MANIFEST_BODY,
MAX_MANIFEST_PLAINTEXT,
PROTOCOL_VERSION,
READ_VALUES,
LEASE_IDLE_MS,
UPLOAD_RESERVATION_MS,
type ExpiryChoice,
type ReadChoice,
type ReserveRequest,
} from "../types/drop";
export const ROOM_ID_PATTERN = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{32}$/u;
export const B64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
const MAX_CHUNKS = 0xfffffffe;
export const EXPIRY_MS: Record<ExpiryChoice, number> = {
"1h": 3600000,
"2h": 7200000,
"6h": 21600000,
"12h": 43200000,
"24h": 86400000,
"7d": 604800000,
};
export function isExpiry(value: unknown): value is ExpiryChoice {
return (
typeof value === "string" &&
(EXPIRY_VALUES as readonly string[]).includes(value)
);
}
export function isReadLimit(value: unknown): value is ReadChoice {
return (
value === null ||
(typeof value === "number" && READ_VALUES.includes(value as never))
);
}
export function isBase64Url(value: unknown, bytes?: number): value is string {
return (
typeof value === "string" &&
value.length > 0 &&
value.length <= 512 &&
B64URL_PATTERN.test(value) &&
(!bytes ||
Math.ceil((value.length * 6) / 8) === bytes ||
value.length === Math.ceil((bytes * 8) / 6))
);
}
export function decodeBase64Url(value: string): Uint8Array {
if (!B64URL_PATTERN.test(value) || value.length % 4 === 1)
throw new Error("Invalid base64url");
const normalized = value
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(normalized);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
export function validateRoomId(id: unknown): asserts id is string {
if (typeof id !== "string" || !ROOM_ID_PATTERN.test(id))
throw new Error("Invalid drop id");
}
export function validateReservation(input: unknown): ReserveRequest {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new Error("Invalid reservation");
const value = input as Record<string, unknown>;
const allowed = [
"version",
"chunkSize",
"chunkCount",
"noncePrefix",
"expiresIn",
"maxReads",
];
if (Object.keys(value).some((key) => !allowed.includes(key)))
throw new Error("Unknown reservation field");
const chunkCount = value.chunkCount;
if (
value.version !== PROTOCOL_VERSION ||
value.chunkSize !== CHUNK_SIZE ||
!Number.isSafeInteger(chunkCount) ||
(chunkCount as number) < 1 ||
(chunkCount as number) > MAX_CHUNKS
)
throw new Error("Invalid chunk geometry");
if (
!isExpiry(value.expiresIn) ||
!isReadLimit(value.maxReads) ||
!isBase64Url(value.noncePrefix, 8)
)
throw new Error("Invalid reservation policy");
if (decodeBase64Url(value.noncePrefix as string).byteLength !== 8)
throw new Error("Invalid nonce prefix");
return value as unknown as ReserveRequest;
}
export function validateChunkIndex(index: unknown, count: number): number {
const parsed = typeof index === "number" ? index : Number(index);
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed >= count)
throw new Error("Invalid chunk index");
return parsed;
}
export function validateChunkBodyLength(length: number, final: boolean): void {
if (
!Number.isSafeInteger(length) ||
length < (final ? GCM_TAG_BYTES : MAX_CHUNK_BODY) ||
length > MAX_CHUNK_BODY
)
throw new Error("Invalid chunk body length");
}
export function validateManifestBodyLength(length: number): void {
if (
!Number.isSafeInteger(length) ||
length <= GCM_TAG_BYTES ||
length > MAX_MANIFEST_BODY
)
throw new Error("Invalid manifest body length");
}
export function expiresInMs(value: ExpiryChoice): number {
return EXPIRY_MS[value];
}

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { loadModule } from "./test-helpers.mjs";
const contract = await loadModule("shared/utils/drop-contract.ts");
const types = await loadModule("shared/types/drop.ts");
const valid = {
version: 2,
chunkSize: types.CHUNK_SIZE,
chunkCount: 1,
noncePrefix: "AQIDBAUGBwg",
expiresIn: "24h",
maxReads: 1,
};
test("reservation validation accepts protocol geometry and rejects unsafe policies", () => {
assert.deepEqual(contract.validateReservation(valid), valid);
for (const bad of [
{ ...valid, chunkSize: 1 },
{ ...valid, chunkCount: 0 },
{ ...valid, chunkCount: 1.5 },
{ ...valid, expiresIn: "forever" },
{ ...valid, maxReads: 2 },
{ ...valid, unknown: true },
{ ...valid, noncePrefix: "AA" },
])
assert.throws(() => contract.validateReservation(bad));
});
test("manifest body must contain plaintext as well as the GCM tag", () => {
assert.throws(() => contract.validateManifestBodyLength(types.GCM_TAG_BYTES));
assert.doesNotThrow(() =>
contract.validateManifestBodyLength(types.GCM_TAG_BYTES + 1),
);
});

126
tests/drop-crypto.test.mjs Normal file
View File

@@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
import { loadModule } from "./test-helpers.mjs";
const crypto = await loadModule("app/utils/crypto.ts");
const id = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const key = crypto.generateKey();
const wrongKey = crypto.generateKey();
const nonce = crypto.generateNoncePrefix();
test("AES-GCM manifest and chunk round trips preserve plaintext", async () => {
const plain = new TextEncoder().encode("hello chunk");
const cipher = await crypto.encryptChunk(
plain,
key,
id,
nonce,
0,
8 * 1024 * 1024,
1,
);
assert.deepEqual(
[
...(await crypto.decryptChunk(
cipher,
key,
id,
nonce,
0,
plain.byteLength,
8 * 1024 * 1024,
1,
)),
],
[...plain],
);
const manifest = {
version: 2,
name: "hello.txt",
type: "text/plain",
size: plain.byteLength,
lastModified: 1,
chunkSize: 8 * 1024 * 1024,
chunkCount: 1,
};
const encrypted = await crypto.encryptManifest(manifest, key, id, nonce);
assert.deepEqual(
await crypto.decryptManifest(
encrypted,
key,
id,
nonce,
manifest.chunkSize,
manifest.chunkCount,
),
manifest,
);
});
test("empty chunks are authenticated and round-trip", async () => {
const cipher = await crypto.encryptChunk(
new Uint8Array(),
key,
id,
nonce,
0,
8 * 1024 * 1024,
1,
);
assert.equal(cipher.byteLength, 16);
assert.equal(
(
await crypto.decryptChunk(
cipher,
key,
id,
nonce,
0,
0,
8 * 1024 * 1024,
1,
)
).byteLength,
0,
);
});
test("tampering and wrong key or AAD fail integrity", async () => {
const cipher = await crypto.encryptChunk(
new Uint8Array([1, 2, 3]),
key,
id,
nonce,
0,
8 * 1024 * 1024,
1,
);
const tampered = new Uint8Array(cipher);
tampered[0] ^= 1;
await assert.rejects(
crypto.decryptChunk(tampered, key, id, nonce, 0, 3, 8 * 1024 * 1024, 1),
/integrity/,
);
await assert.rejects(
crypto.decryptChunk(cipher, wrongKey, id, nonce, 0, 3, 8 * 1024 * 1024, 1),
/integrity/,
);
await assert.rejects(
crypto.decryptChunk(cipher, key, id, nonce, 1, 3, 8 * 1024 * 1024, 1),
/integrity/,
);
});
test("tag-only manifests are rejected before decryption", async () => {
await assert.rejects(
crypto.decryptManifest(
new Uint8Array(16),
key,
id,
nonce,
8 * 1024 * 1024,
1,
),
/empty/,
);
});

153
tests/drop-store.test.mjs Normal file
View File

@@ -0,0 +1,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, stat, utimes, access } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadModule } from "./test-helpers.mjs";
const { DropStore, DropStoreError, pathsFor } = await loadModule(
"server/utils/drop-store.ts",
);
const { CHUNK_SIZE } = await loadModule("shared/types/drop.ts");
const noncePrefix = "AQIDBAUGBwg";
let roots = [];
async function fresh(clock = { now: () => Date.now() }) {
const root = await mkdtemp(join(tmpdir(), "quickdrop-test-"));
roots.push(root);
return { store: new DropStore({ root, clock }), root };
}
async function reserve(store, maxReads = 1, expiresIn = "24h") {
return store.reserve({
version: 2,
chunkSize: CHUNK_SIZE,
chunkCount: 1,
noncePrefix,
expiresIn,
maxReads,
});
}
async function ready(store, maxReads = 1, expiresIn = "24h") {
const result = await reserve(store, maxReads, expiresIn);
await store.putChunk(result.id, 0, new Uint8Array(16));
await store.finalize(result.id, new Uint8Array(17));
return result;
}
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
test.after(async () => {
await Promise.all(
roots.map((root) => rm(root, { recursive: true, force: true })),
);
});
test("finalize starts expiry and rejects missing chunks", async () => {
let now = 1000;
const { store } = await fresh({ now: () => now });
const result = await reserve(store, 1, "1h");
await assert.rejects(
store.finalize(result.id, new Uint8Array(17)),
(error) => error instanceof DropStoreError && error.code === "incomplete",
);
await store.putChunk(result.id, 0, new Uint8Array(16));
const metadata = await store.finalize(result.id, new Uint8Array(17));
assert.equal(metadata.expiresAt, now + 3600000);
now += 3599999;
assert.equal(
(await store.descriptor(result.id, result.access)).id,
result.id,
);
now++;
await assert.rejects(
store.descriptor(result.id, result.access),
(error) => error instanceof DropStoreError && error.code === "expired",
);
});
test("chunk retries are idempotent and conflicting contents are rejected", async () => {
const { store } = await fresh();
const result = await reserve(store);
const body = new Uint8Array(16).fill(7);
assert.equal(await store.putChunk(result.id, 0, body), "created");
assert.equal(await store.putChunk(result.id, 0, body), "same");
await assert.rejects(
store.putChunk(result.id, 0, new Uint8Array(16).fill(8)),
(error) => error instanceof DropStoreError && error.code === "conflict",
);
});
test("maxReads claims are serialized, valid lease reuse does not increment, and completion cleans up", async () => {
const { store, root } = await fresh();
const result = await ready(store, 1);
const claims = await Promise.allSettled([
store.claim(result.id, result.access),
store.claim(result.id, result.access),
]);
assert.equal(
claims.filter((value) => value.status === "fulfilled").length,
1,
);
assert.equal(claims.filter((value) => value.status === "rejected").length, 1);
const first = claims.find((value) => value.status === "fulfilled").value;
const metadataBefore = await store.readMetadata(result.id);
const reused = await store.claim(result.id, result.access, first.token);
assert.equal(reused.reused, true);
assert.equal(
(await store.readMetadata(result.id)).readsClaimed,
metadataBefore.readsClaimed,
);
const chunk = await store.readChunk(result.id, 0, first.token);
assert.equal(chunk.size, 16);
await chunk.handle.close();
await store.complete(result.id, first.token);
assert.equal(await exists(pathsFor(result.id, root).directory), false);
});
test("unlimited drops allow multiple leases and metadata remains parseable", async () => {
const { store, root } = await fresh();
const result = await ready(store, null);
const one = await store.claim(result.id, result.access);
const two = await store.claim(result.id, result.access);
assert.notEqual(one.token, two.token);
assert.equal((await store.readMetadata(result.id)).readsClaimed, 2);
JSON.parse(await readFile(pathsFor(result.id, root).metadata, "utf8"));
await store.complete(result.id, one.token);
assert.equal(await exists(pathsFor(result.id, root).directory), true);
});
test("idle lease cleanup and stale temporary files are safe", async () => {
let now = 100000;
const { store, root } = await fresh({ now: () => now });
const result = await ready(store, 1);
await store.claim(result.id, result.access);
const paths = pathsFor(result.id, root);
const stale = join(paths.directory, ".chunk-stale");
await (await import("node:fs/promises")).writeFile(stale, "stale");
const old = new Date(now - 2 * 3600000);
await utimes(stale, old, old);
now += 16 * 60 * 1000;
await store.cleanup();
assert.equal(await exists(stale), false);
assert.equal(await exists(paths.directory), false);
});
test("metadata-less valid directories are removed only after grace period", async () => {
let now = 10_000_000;
const { store, root } = await fresh({ now: () => now });
const result = await reserve(store);
const paths = pathsFor(result.id, root);
await rm(paths.metadata);
const recent = new Date(now);
await utimes(paths.directory, recent, recent);
await store.cleanup();
assert.equal(await exists(paths.directory), true);
const old = new Date(now - 2 * 3600000);
await utimes(paths.directory, old, old);
await store.cleanup();
assert.equal(await exists(paths.directory), false);
});

207
tests/http-smoke.test.mjs Normal file
View File

@@ -0,0 +1,207 @@
import test from "node:test";
import assert from "node:assert/strict";
import { access, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
try {
await access(".output/server/index.mjs");
} catch {
await promisify(execFile)("npm", ["run", "build"], { stdio: "inherit" });
}
const port = 19000 + Math.floor(Math.random() * 500);
const storage = await mkdtemp(join(tmpdir(), "quickdrop-http-"));
const child = spawn(process.execPath, [".output/server/index.mjs"], {
env: {
...process.env,
NITRO_HOST: "127.0.0.1",
NITRO_PORT: String(port),
AUTH_PASSCODE: "smoke-passcode",
AUTH_SECRET: "smoke-secret-for-tests",
QUICKDROP_STORAGE_DIR: storage,
},
stdio: "ignore",
});
async function request(url, init) {
return fetch(`http://127.0.0.1:${port}${url}`, init);
}
async function waitForServer() {
for (let attempt = 0; attempt < 50; attempt++) {
try {
const response = await request("/api/auth/session");
if (response.status === 200) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("built server did not start");
}
test("built server enforces auth and serves capability-only lease/chunk APIs", async () => {
try {
await waitForServer();
const reservationBody = JSON.stringify({
version: 2,
chunkSize: 8 * 1024 * 1024,
chunkCount: 1,
noncePrefix: "AQIDBAUGBwg",
expiresIn: "24h",
maxReads: 1,
});
assert.equal(
(
await request("/api/drops", {
method: "POST",
headers: { "content-type": "application/json" },
body: reservationBody,
})
).status,
401,
);
const login = await request("/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
origin: `http://127.0.0.1:${port}`,
},
body: JSON.stringify({ passcode: "smoke-passcode" }),
});
assert.equal(login.status, 204);
const cookie = login.headers.get("set-cookie").split(";", 1)[0];
const reserve = await request("/api/drops", {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: reservationBody,
});
assert.equal(reserve.status, 201);
const created = await reserve.json();
const chunk = new Uint8Array(16);
const put = await request(`/api/drops/${created.id}/chunks/0`, {
method: "PUT",
headers: {
"content-type": "application/octet-stream",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: chunk,
});
assert.equal(put.status, 201);
const finalize = await request(`/api/drops/${created.id}/finalize`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: new Uint8Array(17),
});
assert.equal(finalize.status, 200);
const descriptor = await request(`/api/drops/${created.id}`, {
headers: { "x-quickdrop-access": created.access },
});
assert.equal(descriptor.status, 200);
const first = await request(`/api/drops/${created.id}/leases`, {
method: "POST",
headers: { "x-quickdrop-access": created.access },
});
assert.equal(first.status, 201);
const lease = await first.json();
const second = await request(`/api/drops/${created.id}/leases`, {
method: "POST",
headers: { "x-quickdrop-access": created.access },
});
assert.equal(second.status, 410);
const reused = await request(`/api/drops/${created.id}/leases`, {
method: "POST",
headers: {
"x-quickdrop-access": created.access,
authorization: `Bearer ${lease.token}`,
},
});
assert.equal(reused.status, 200);
assert.equal((await reused.json()).token, lease.token);
const chunkGet = await request(`/api/drops/${created.id}/chunks/0`, {
headers: { authorization: `Bearer ${lease.token}` },
});
assert.equal(chunkGet.status, 200);
assert.equal((await chunkGet.arrayBuffer()).byteLength, 16);
const complete = await request(`/api/drops/${created.id}/complete`, {
method: "POST",
headers: { authorization: `Bearer ${lease.token}` },
});
assert.equal(complete.status, 204);
// Completion retries must also be idempotent when the drop remains (unlimited reads).
const unlimitedBody = JSON.stringify({
...JSON.parse(reservationBody),
maxReads: null,
});
const unlimitedReserve = await request("/api/drops", {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: unlimitedBody,
});
assert.equal(unlimitedReserve.status, 201);
const unlimited = await unlimitedReserve.json();
assert.equal(
(
await request(`/api/drops/${unlimited.id}/chunks/0`, {
method: "PUT",
headers: {
"content-type": "application/octet-stream",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: chunk,
})
).status,
201,
);
assert.equal(
(
await request(`/api/drops/${unlimited.id}/finalize`, {
method: "POST",
headers: {
"content-type": "application/octet-stream",
cookie,
origin: `http://127.0.0.1:${port}`,
},
body: new Uint8Array(17),
})
).status,
200,
);
const unlimitedClaim = await request(`/api/drops/${unlimited.id}/leases`, {
method: "POST",
headers: { "x-quickdrop-access": unlimited.access },
});
assert.equal(unlimitedClaim.status, 201);
const unlimitedLease = await unlimitedClaim.json();
const completionInit = {
method: "POST",
headers: { authorization: `Bearer ${unlimitedLease.token}` },
};
assert.equal(
(await request(`/api/drops/${unlimited.id}/complete`, completionInit))
.status,
204,
);
assert.equal(
(await request(`/api/drops/${unlimited.id}/complete`, completionInit))
.status,
204,
);
} finally {
child.kill("SIGTERM");
await rm(storage, { recursive: true, force: true });
}
});

16
tests/test-helpers.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { createServer } from "vite";
import { resolve } from "node:path";
export const root = resolve(new URL("..", import.meta.url).pathname);
export async function loadModule(path) {
const vite = await createServer({
root,
logLevel: "error",
resolve: { alias: { "~": resolve(root, "app") } },
});
try {
return await vite.ssrLoadModule(`/${path}`);
} finally {
await vite.close();
}
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

274
uno.config.ts Normal file
View File

@@ -0,0 +1,274 @@
import { defineConfig, presetUno } from "unocss";
const colors = {
bg: "#080d16",
"bg-deep": "#050910",
panel: "#101722",
"panel-raised": "#151e2b",
border: "#263242",
"border-soft": "#1d2836",
text: "#f4f7f5",
muted: "#8f9bab",
"muted-bright": "#b9c2cc",
accent: "#46e69e",
"accent-strong": "#28d985",
"accent-ink": "#062317",
danger: "#ff7f7f",
};
export default defineConfig({
presets: [presetUno({ preflight: false })],
safelist: [
"site-shell",
"site-header",
"brand",
"brand-mark",
"brand-i",
"status-dot",
"page",
"hero",
"eyebrow",
"hero-copy",
"rail-layout",
"panel",
"panel-main",
"panel-side",
"drop-panel",
"drop-zone",
"drop-icon",
"button",
"button-large",
"button-secondary",
"button-small",
"side-heading",
"feature-list",
"feature",
"feature-icon",
"selected-file",
"selected-card",
"selected-icon",
"selected-meta",
"selected-name",
"selected-detail",
"icon-button",
"selected-actions",
"privacy-note",
"error-message",
"success-title",
"success-copy",
"share-label",
"share-box",
"share-value",
"success-actions",
"qr-panel",
"qr-frame",
"qr-title",
"qr-copy",
"progress-wrap",
"progress-track",
"progress-bar",
"progress-label",
"download-page",
"download-heading",
"download-intro",
"download-card",
"ready-badge",
"download-file",
"download-button",
"preview",
"preview-caption",
"state-box",
"spinner",
"site-footer",
"footer-security",
],
theme: {
colors,
fontFamily: {
display: "'Instrument Sans', ui-sans-serif, system-ui, sans-serif",
body: "'Instrument Sans', ui-sans-serif, system-ui, sans-serif",
mono: "ui-monospace, SFMono-Regular, Menlo, monospace",
},
breakpoints: {
// Uno's lt-* variants subtract .1px; offset these to preserve the original inclusive max-width queries.
sm: "560.1px",
md: "800.1px",
},
transitionProperty: {
drop: "border-color, background, transform",
button: "transform, background, box-shadow",
},
animation: {
keyframes: {
progress:
"{0%{transform:translateX(-110%)}100%{transform:translateX(290%)}}",
spin: "{to{transform:rotate(360deg)}}",
},
},
},
shortcuts: {
"site-shell":
"w-[min(1180px,calc(100%_-_40px))] min-h-screen mx-auto flex flex-col lt-sm:w-[min(100%_-_26px,1180px)]",
"site-header": "h-16 flex items-center justify-between lt-sm:h-[72px]",
brand:
"inline-flex items-center gap-[11px] text-[var(--text)] font-display text-[21px] font-800 tracking-[-.04em] no-underline",
"brand-mark":
"w-[33px] h-[33px] grid place-items-center border border-[rgba(70,230,158,.3)] rounded-[10px] text-accent bg-[rgba(70,230,158,.08)] [&>svg]:w-[19px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-width-[1.8] [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
"brand-i":
"relative after:absolute after:left-[22%] after:top-[4.5px] after:h-[3.5px] after:w-[3.5px] after:-translate-x-[22%] after:bg-accent after:content-empty",
"status-dot":
"w-[7px] h-[7px] rounded-full bg-accent shadow-[0_0_12px_rgba(70,230,158,.75)]",
page: "pt-4 pb-[64px] lt-md:pt-[50px] lt-sm:pt-[40px] lt-sm:pb-[50px]",
hero: "max-w-[720px] mb-[44px] lt-sm:mb-[30px]",
eyebrow:
"m-0 mb-[15px] text-accent text-[12px] font-700 tracking-[.14em] uppercase",
"hero-copy":
"max-w-[600px] m-0 text-[var(--muted)] text-[18px] leading-[1.65] lt-sm:text-[16px]",
"rail-layout":
"grid grid-cols-[minmax(0,1.18fr)_minmax(320px,.82fr)] gap-[22px] items-stretch lt-md:grid-cols-[minmax(0,1fr)]",
panel:
"min-w-0 border border-[var(--border)] rounded-[20px] bg-[rgba(16,23,34,.9)] shadow-[0_22px_70px_rgba(0,0,0,.18)]",
"panel-main": "min-h-[418px] p-[clamp(25px,4vw,42px)] lt-sm:p-[22px]",
"panel-side": "p-[clamp(25px,3vw,36px)] lt-md:min-h-auto lt-sm:p-[22px]",
"drop-panel": "flex flex-col",
"drop-zone":
"my-auto min-h-[282px] flex flex-col items-center justify-center p-[35px] border border-dashed border-[#35475a] rounded-[15px] text-center cursor-pointer bg-[rgba(8,13,22,.48)] transition-drop duration-200 hover:border-accent hover:bg-[rgba(70,230,158,.04)] hover:translate-y-[-2px] [&.is-dragging]:border-accent [&.is-dragging]:bg-[rgba(70,230,158,.04)] [&.is-dragging]:translate-y-[-2px] [&:has(.file-input:focus-visible)]:border-accent [&:has(.file-input:focus-visible)]:bg-[rgba(70,230,158,.04)] [&:has(.file-input:focus-visible)]:translate-y-[-2px] [&:has(.file-input:focus-visible)]:outline-[3px] [&:has(.file-input:focus-visible)]:outline-[rgba(70,230,158,.35)] [&:has(.file-input:focus-visible)]:outline-offset-[3px] [&>h2]:mb-[8px] [&>h2]:text-[21px] [&>h2]:tracking-[-.025em] [&>p]:mb-[21px] [&>p]:text-[var(--muted)] [&>p]:text-[14px] lt-sm:min-h-[250px] lt-sm:py-[25px] lt-sm:px-[15px]",
"drop-icon":
"w-[54px] h-[54px] grid place-items-center mb-[20px] rounded-[15px] text-accent bg-[rgba(70,230,158,.1)] [&>svg]:w-[27px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-width-[1.7] [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
button:
"min-h-[48px] inline-flex items-center justify-center gap-[10px] px-[20px] rounded-[10px] text-[var(--accent-ink)] bg-accent font-700 no-underline cursor-pointer transition-button duration-180 hover:not-disabled:bg-[#5ef0ae] hover:not-disabled:translate-y-[-2px] hover:not-disabled:shadow-[0_10px_28px_rgba(70,230,158,.16)] disabled:cursor-not-allowed disabled:opacity-55 [&>svg]:w-[20px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-2 [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
"button-large": "min-h-[58px] w-full text-[16px]",
"button-secondary":
"text-[var(--text)] border border-[var(--border)] bg-[var(--panel-raised)] hover:not-disabled:bg-[#1a2533] hover:not-disabled:shadow-none",
"button-small": "min-h-[39px] px-[14px] text-[13px]",
"side-heading": "mb-[24px] text-[17px] tracking-[-.02em]",
"feature-list": "grid gap-[23px] m-0 p-0 list-none",
feature:
"grid grid-cols-[38px_1fr] gap-[14px] [&_h3]:m-[1px_0_4px] [&_h3]:text-[14px] [&_p]:m-0 [&_p]:text-[var(--muted)] [&_p]:text-[13px] [&_p]:leading-[1.5]",
"feature-icon":
"w-[38px] h-[38px] grid place-items-center border border-[rgba(70,230,158,.16)] rounded-[10px] text-accent bg-[rgba(70,230,158,.06)] [&>svg]:w-[18px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-width-[1.7] [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
"selected-file": "h-full flex flex-col justify-center",
"selected-card":
"flex items-center gap-[16px] p-[17px] border border-[var(--border)] rounded-[13px] bg-[var(--bg)]",
"selected-icon":
"w-[49px] h-[49px] flex-none grid place-items-center rounded-[12px] text-accent bg-[rgba(70,230,158,.09)] [&>svg]:w-[24px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-width-[1.7] [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
"selected-meta": "min-w-0 flex-1",
"selected-name":
"m-0 mb-[3px] overflow-hidden text-[var(--text)] font-600 text-ellipsis whitespace-nowrap",
"selected-detail": "m-0 text-[var(--muted)] text-[13px]",
"icon-button":
"w-[38px] h-[38px] flex-none grid place-items-center rounded-[9px] text-[var(--muted)] bg-transparent cursor-pointer hover:text-[var(--text)] hover:bg-[var(--panel-raised)] [&>svg]:w-[18px] [&>svg]:fill-none [&>svg]:stroke-current [&>svg]:stroke-width-[1.8] [&>svg]:[stroke-linecap:round]",
"selected-actions":
"grid grid-cols-[1fr_auto] gap-[10px] mt-[18px] lt-sm:grid-cols-[1fr]",
"privacy-note":
"flex items-start gap-[9px] m-[21px_0_0] text-[var(--muted)] text-[12px] leading-[1.5] [&>svg]:w-[15px] [&>svg]:min-w-[15px] [&>svg]:mt-[1px] [&>svg]:fill-none [&>svg]:stroke-accent [&>svg]:stroke-width-[1.8]",
"error-message": "m-[16px_0_0] text-danger text-[13px]",
"success-title": "mb-[8px] text-[27px] tracking-[-.04em]",
"success-copy": "mb-[27px] text-[var(--muted)] leading-[1.6]",
"share-label":
"block mb-[9px] text-[var(--muted-bright)] text-[12px] font-600",
"share-box":
"flex gap-[9px] p-[8px] border border-[var(--border)] rounded-[12px] bg-[var(--bg)] lt-sm:flex-col",
"share-value":
"min-w-0 flex-1 p-[10px_7px] overflow-hidden border-0 text-accent bg-transparent font-mono text-[13px] text-ellipsis whitespace-nowrap lt-sm:w-full",
"success-actions": "mt-[28px]",
"qr-panel":
"flex flex-col items-center justify-center text-center lt-md:py-[36px]",
"qr-frame":
"w-[min(100%,300px)] aspect-square p-[16px] rounded-[15px] bg-white shadow-[0_16px_45px_rgba(0,0,0,.24)] lt-md:w-[min(260px,80vw)] [&>svg]:block [&>svg]:w-full [&>svg]:h-full",
"qr-title": "mt-[22px] mb-[7px] text-[16px]",
"qr-copy":
"max-w-[260px] m-0 text-[var(--muted)] text-[13px] leading-[1.55]",
"progress-wrap": "mt-[24px]",
"progress-track": "h-[5px] overflow-hidden rounded-full bg-[#263242]",
"progress-bar":
"w-[38%] h-full rounded-inherit bg-accent animate-[progress_1.3s_ease-in-out_infinite]",
"progress-label": "mt-[11px] text-[var(--muted)] text-[13px]",
"download-page":
"max-w-[920px] mx-auto py-[82px] pb-[72px] lt-sm:pt-[50px]",
"download-heading":
"mb-[13px] text-[clamp(34px,5vw,53px)] text-center tracking-[-.05em]",
"download-intro":
"max-w-[580px] mx-auto mb-[35px] text-[var(--muted)] text-[16px] leading-[1.6] text-center",
"download-card": "max-w-[680px] mx-auto p-[clamp(25px,5vw,42px)]",
"ready-badge":
"w-fit flex items-center gap-[8px] mx-auto mb-[25px] py-[7px] px-[11px] rounded-full text-accent bg-[rgba(70,230,158,.08)] text-[12px] font-700",
"download-file": "mb-[22px]",
"download-button": "mt-[18px]",
preview:
"mt-[26px] overflow-hidden border border-[var(--border)] rounded-[14px] bg-[var(--bg-deep)] [&_img]:w-full [&_img]:max-h-[460px] [&_img]:block [&_img]:object-contain [&_video]:w-full [&_video]:max-h-[460px] [&_video]:block [&_video]:object-contain",
"preview-caption":
"p-[10px_14px] border-t border-[var(--border-soft)] text-[var(--muted)] text-[12px] text-center",
"state-box":
"min-h-[330px] flex flex-col items-center justify-center text-center [&>h2]:mb-[8px] [&>h2]:text-[21px] [&>p]:max-w-[420px] [&>p]:mb-[22px] [&>p]:text-[var(--muted)] [&>p]:leading-[1.55]",
spinner:
"w-[32px] h-[32px] mb-[19px] border-[3px] border-[#263242] border-t-accent rounded-full animate-[spin_.8s_linear_infinite]",
"site-footer":
"min-h-[86px] flex items-center justify-between gap-[20px] border-t border-[var(--border-soft)] text-[var(--muted)] text-[12px] lt-sm:py-[23px] lt-sm:flex-col lt-sm:items-start [&>p]:m-0",
"footer-security":
"flex items-center gap-[7px] [&>svg]:w-[15px] [&>svg]:fill-none [&>svg]:stroke-accent [&>svg]:stroke-width-[1.7] [&>svg]:[stroke-linecap:round] [&>svg]:[stroke-linejoin:round]",
},
preflights: [
{
layer: "base",
getCSS: () => `
@font-face {
font-family: 'Instrument Sans';
src: url('/fonts/InstrumentSans-VariableFont_wdth,wght.woff2') format('woff2');
font-display: swap;
font-style: normal;
font-weight: 400 800;
}
:root {
--bg: #080d16;
--bg-deep: #050910;
--panel: #101722;
--panel-raised: #151e2b;
--border: #263242;
--border-soft: #1d2836;
--text: #f4f7f5;
--muted: #8f9bab;
--muted-bright: #b9c2cc;
--accent: #46e69e;
--accent-strong: #28d985;
--accent-ink: #062317;
--danger: #ff7f7f;
--font-display: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
--font-body: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; border-width: 0; border-style: solid; }
html { min-height: 100%; color-scheme: dark; background: var(--bg); font-family: var(--font-body); }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background: radial-gradient(circle at 78% 8%, rgba(70, 230, 158, .07), transparent 27rem), radial-gradient(circle at 10% 50%, rgba(58, 126, 250, .05), transparent 30rem), var(--bg);
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
content: '';
opacity: .22;
background-image: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: inherit; }
button { border: 0; }
:focus-visible { outline: 3px solid rgba(70,230,158,.5); outline-offset: 3px; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { font-family: var(--font-display); }
h1 { margin-bottom: 17px; font-size: clamp(40px, 6vw, 68px); line-height: 1.04; letter-spacing: -.055em; }
@keyframes progress { 0% { transform: translateX(-110%); } 100% { transform: translateX(290%); } }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
`,
},
],
});