310 lines
9.7 KiB
Vue
310 lines
9.7 KiB
Vue
<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>
|