Initial commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user