Initial commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal 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
25
README.md
Normal 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
46
app/app.vue
Normal 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>
|
||||
26
app/composables/useAuth.ts
Normal file
26
app/composables/useAuth.ts
Normal 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 };
|
||||
}
|
||||
13
app/middleware/auth.global.ts
Normal file
13
app/middleware/auth.global.ts
Normal 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
41
app/pages/auth/index.vue
Normal 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
309
app/pages/index.vue
Normal 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
410
app/pages/room/[id].vue
Normal 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
6
app/types/file-system-access.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
interface Window {
|
||||
showSaveFilePicker?: (options?: {
|
||||
suggestedName?: string;
|
||||
}) => Promise<FileSystemFileHandle>;
|
||||
}
|
||||
export {};
|
||||
297
app/utils/crypto.ts
Normal file
297
app/utils/crypto.ts
Normal 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
30
app/utils/download.ts
Normal 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
7658
aube-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
27
nuxt.config.ts
Normal file
27
nuxt.config.ts
Normal 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
28
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
BIN
public/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
Binary file not shown.
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Disallow: /room/
|
||||
Disallow: /api/
|
||||
35
server/api/auth/login.post.ts
Normal file
35
server/api/auth/login.post.ts
Normal 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);
|
||||
});
|
||||
7
server/api/auth/logout.post.ts
Normal file
7
server/api/auth/logout.post.ts
Normal 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);
|
||||
});
|
||||
5
server/api/auth/session.get.ts
Normal file
5
server/api/auth/session.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineEventHandler } from "h3";
|
||||
import { isAuthenticated } from "../../utils/auth";
|
||||
export default defineEventHandler((event) => ({
|
||||
authenticated: isAuthenticated(event),
|
||||
}));
|
||||
25
server/api/drops/[id].get.ts
Normal file
25
server/api/drops/[id].get.ts
Normal 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." });
|
||||
}
|
||||
});
|
||||
72
server/api/drops/[id]/chunks/[index].get.ts
Normal file
72
server/api/drops/[id]/chunks/[index].get.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
30
server/api/drops/[id]/chunks/[index].put.ts
Normal file
30
server/api/drops/[id]/chunks/[index].put.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
32
server/api/drops/[id]/complete.post.ts
Normal file
32
server/api/drops/[id]/complete.post.ts
Normal 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);
|
||||
});
|
||||
37
server/api/drops/[id]/finalize.post.ts
Normal file
37
server/api/drops/[id]/finalize.post.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
39
server/api/drops/[id]/leases.post.ts
Normal file
39
server/api/drops/[id]/leases.post.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
26
server/api/drops/index.post.ts
Normal file
26
server/api/drops/index.post.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
16
server/plugins/drop-cleanup.ts
Normal file
16
server/plugins/drop-cleanup.ts
Normal 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
104
server/utils/auth.ts
Normal 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
580
server/utils/drop-store.ts
Normal 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
55
server/utils/http.ts
Normal 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
79
shared/types/drop.ts
Normal 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;
|
||||
}
|
||||
121
shared/utils/drop-contract.ts
Normal file
121
shared/utils/drop-contract.ts
Normal 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];
|
||||
}
|
||||
35
tests/drop-contract.test.mjs
Normal file
35
tests/drop-contract.test.mjs
Normal 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
126
tests/drop-crypto.test.mjs
Normal 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
153
tests/drop-store.test.mjs
Normal 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
207
tests/http-smoke.test.mjs
Normal 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
16
tests/test-helpers.mjs
Normal 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
18
tsconfig.json
Normal 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
274
uno.config.ts
Normal 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; }
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user