Adds a "Via AnyDrop" flow for senders who need to reach someone not present on the mesh. The file is sealed client-side (XChaCha20-Poly1305), uploaded directly to an in-cluster MinIO bucket via a presigned PUT, and handed off to the recipient as a URL whose fragment carries the key. The server only ever sees ciphertext, opaque metadata blobs, and sizes. - server: transfers table (drizzle migration), /api/transfers CRUD + consume endpoint, presigned PUT/GET via @aws-sdk/client-s3, cleanup loop that purges expired + exhausted blobs. - web: @noble/ciphers sealFile/openFile, high-level sendCloud/receive helpers, CloudSharePanel on Home, /r/:id receive page, /inbox page for signed-in users (sent + received tabs). - k8s: MinIO StatefulSet with bucket-init initContainer, S3 env vars on the server Deployment (credentials pulled from minio-credentials Secret). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
import {
|
|
sealFile,
|
|
openMetadata,
|
|
openFile,
|
|
keyToFragment,
|
|
fragmentToKey,
|
|
type TransferMetadata,
|
|
} from "./cloudTransfer";
|
|
import {
|
|
createTransfer,
|
|
consumeTransfer,
|
|
getTransferHead,
|
|
type TransferHead,
|
|
} from "./api";
|
|
|
|
export interface SendCloudOptions {
|
|
recipientEmail?: string;
|
|
expiresInDays?: number;
|
|
maxDownloads?: number;
|
|
deviceId?: 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,
|
|
});
|
|
|
|
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
|
|
|
|
const origin =
|
|
typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.origin;
|
|
const shareUrl = `${origin}/r/${created.transferId}#k=${keyToFragment(key)}`;
|
|
|
|
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,
|
|
onProgress?: (loaded: number, total: number) => void,
|
|
): Promise<File> {
|
|
const { downloadUrl } = await consumeTransfer(transferId);
|
|
|
|
const ciphertext = await downloadWithProgress(downloadUrl, 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();
|
|
});
|
|
}
|