anydrop/web/src/lib/sendCloud.ts
ordinarthur c46b23b8ec
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
feat: shared-links history in /settings with revoke + copy
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 12:26:04 +02:00

166 lines
4.6 KiB
TypeScript

import {
sealFile,
openMetadata,
openFile,
keyToFragment,
fragmentToKey,
type TransferMetadata,
} from "./cloudTransfer";
import {
createTransfer,
consumeTransfer,
getTransferHead,
type TransferHead,
} from "./api";
import { saveTransferKey } from "./localTransferKeys";
export interface SendCloudOptions {
recipientEmail?: string;
expiresInDays?: number;
maxDownloads?: number;
deviceId?: string;
password?: string;
onProgress?: (loaded: number, total: number) => void;
}
export interface SendCloudResult {
transferId: string;
shareUrl: string;
expiresAt: string;
}
/**
* End-to-end cloud send:
* 1. Encrypt the file + metadata locally under a fresh random key.
* 2. Register the transfer with the server — sending only ciphertext
* metadata and ciphertext size. Receive a presigned PUT URL.
* 3. Upload ciphertext directly to MinIO (the server never sees it).
* 4. Return a share URL with the key in the fragment.
*/
export async function sendCloud(
file: File,
options: SendCloudOptions = {},
): Promise<SendCloudResult> {
const { key, encryptedBody, encryptedMetadata } = await sealFile(file);
const created = await createTransfer({
sizeBytes: encryptedBody.length,
encryptedMetadata,
recipientEmail: options.recipientEmail,
maxDownloads: options.maxDownloads,
expiresInDays: options.expiresInDays,
deviceId: options.deviceId,
password: options.password,
});
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
const origin =
typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.origin;
const keyFrag = keyToFragment(key);
const shareUrl = `${origin}/r/${created.transferId}#k=${keyFrag}`;
saveTransferKey(created.transferId, keyFrag);
return {
transferId: created.transferId,
shareUrl,
expiresAt: created.expiresAt,
};
}
function uploadWithProgress(
url: string,
body: Uint8Array,
onProgress?: (loaded: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("upload network error"));
xhr.send(body as BlobPart);
});
}
export interface ReceivedTransferPreview {
head: TransferHead;
metadata: TransferMetadata;
}
export function parseKeyFromLocation(): Uint8Array | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash;
if (!hash.startsWith("#")) return null;
const params = new URLSearchParams(hash.slice(1));
const k = params.get("k");
if (!k) return null;
try {
return fragmentToKey(k);
} catch {
return null;
}
}
/**
* Fetch (but don't consume) the transfer metadata — lets the recipient
* UI render "you're about to accept X MB from sender Y" before committing
* a download slot.
*/
export async function previewTransfer(
transferId: string,
key: Uint8Array,
): Promise<ReceivedTransferPreview> {
const head = await getTransferHead(transferId);
const metadata = openMetadata(key, head.encryptedMetadata);
return { head, metadata };
}
/**
* Claim a download slot, fetch the ciphertext, decrypt, return a File.
*/
export async function receiveCloud(
transferId: string,
key: Uint8Array,
metadata: TransferMetadata,
options: {
password?: string;
onProgress?: (loaded: number, total: number) => void;
} = {},
): Promise<File> {
const { downloadUrl } = await consumeTransfer(transferId, options.password);
const ciphertext = await downloadWithProgress(downloadUrl, options.onProgress);
return openFile(key, ciphertext, metadata);
}
function downloadWithProgress(
url: string,
onProgress?: (loaded: number, total: number) => void,
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "arraybuffer";
xhr.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(new Uint8Array(xhr.response as ArrayBuffer));
} else {
reject(new Error(`download failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("download network error"));
xhr.send();
});
}