411 lines
13 KiB
Vue
411 lines
13 KiB
Vue
<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>
|