diff --git a/xftp-web/.gitignore b/xftp-web/.gitignore index 320c107b3..507b50d80 100644 --- a/xftp-web/.gitignore +++ b/xftp-web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ +dist-web/ package-lock.json diff --git a/xftp-web/package.json b/xftp-web/package.json index 3da8db60c..136681279 100644 --- a/xftp-web/package.json +++ b/xftp-web/package.json @@ -9,15 +9,24 @@ "postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs", "build": "tsc", "test": "node --experimental-vm-modules node_modules/.bin/jest", - "test:browser": "vitest --run" + "test:browser": "vitest --run", + "dev": "vite --mode development", + "build:local": "vite build --mode development", + "build:prod": "vite build --mode production", + "preview": "vite preview", + "preview:prod": "vite build --mode production && vite preview", + "check:web": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.worker.json --noEmit", + "test:page": "playwright test test/page.spec.ts" }, "devDependencies": { "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/node": "^20.0.0", "@types/pako": "^2.0.3", "@vitest/browser": "^3.0.0", + "@playwright/test": "^1.50.0", "playwright": "^1.50.0", "typescript": "^5.4.0", + "vite": "^6.0.0", "vitest": "^3.0.0" }, "dependencies": { diff --git a/xftp-web/playwright.config.ts b/xftp-web/playwright.config.ts new file mode 100644 index 000000000..ce32b12f4 --- /dev/null +++ b/xftp-web/playwright.config.ts @@ -0,0 +1,19 @@ +import {defineConfig} from '@playwright/test' + +export default defineConfig({ + testDir: './test', + testMatch: '**/*.spec.ts', + timeout: 60_000, + use: { + ignoreHTTPSErrors: true, + launchOptions: { + args: ['--ignore-certificate-errors'] + } + }, + webServer: { + command: 'npx vite build --mode development && npx vite preview', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI + }, + globalSetup: './test/globalSetup.ts' +}) diff --git a/xftp-web/src/agent.ts b/xftp-web/src/agent.ts index aa833032b..8bea8c2ae 100644 --- a/xftp-web/src/agent.ts +++ b/xftp-web/src/agent.ts @@ -15,11 +15,11 @@ import { } from "./protocol/description.js" import type {FileInfo} from "./protocol/commands.js" import { - getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, + getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw, ackXFTPChunk, deleteXFTPChunk, type XFTPClientAgent } from "./client.js" export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent} from "./client.js" -import {processDownloadedFile} from "./download.js" +import {processDownloadedFile, decryptReceivedChunk} from "./download.js" import type {XFTPServer} from "./protocol/address.js" import {formatXFTPServer, parseXFTPServer} from "./protocol/address.js" import {concatBytes} from "./protocol/encoding.js" @@ -38,14 +38,17 @@ interface SentChunk { server: XFTPServer } -export interface EncryptedFileInfo { - encData: Uint8Array +export interface EncryptedFileMetadata { digest: Uint8Array // SHA-512 of encData key: Uint8Array // 32B SbKey nonce: Uint8Array // 24B CbNonce chunkSizes: number[] } +export interface EncryptedFileInfo extends EncryptedFileMetadata { + encData: Uint8Array +} + export interface UploadResult { rcvDescription: FileDescription sndDescription: FileDescription @@ -93,13 +96,25 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr const DEFAULT_REDIRECT_THRESHOLD = 400 +export interface UploadOptions { + onProgress?: (uploaded: number, total: number) => void + redirectThreshold?: number + readChunk?: (offset: number, size: number) => Promise +} + export async function uploadFile( agent: XFTPClientAgent, server: XFTPServer, - encrypted: EncryptedFileInfo, - onProgress?: (uploaded: number, total: number) => void, - redirectThreshold?: number + encrypted: EncryptedFileMetadata, + options?: UploadOptions ): Promise { + const {onProgress, redirectThreshold, readChunk: readChunkOpt} = options ?? {} + const readChunk: (offset: number, size: number) => Promise = readChunkOpt + ? readChunkOpt + : ('encData' in encrypted + ? (off, sz) => Promise.resolve((encrypted as EncryptedFileInfo).encData.subarray(off, off + sz)) + : () => { throw new Error("uploadFile: readChunk required when encData is absent") }) + const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0) const specs = prepareChunkSpecs(encrypted.chunkSizes) const client = await getXFTPServerClient(agent, server) const sentChunks: SentChunk[] = [] @@ -109,7 +124,7 @@ export async function uploadFile( const chunkNo = i + 1 const sndKp = generateEd25519KeyPair() const rcvKp = generateEd25519KeyPair() - const chunkData = encrypted.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize) + const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize) const chunkDigest = getChunkDigest(chunkData) const fileInfo: FileInfo = { sndKey: encodePubKeyEd25519(sndKp.publicKey), @@ -126,7 +141,7 @@ export async function uploadFile( chunkSize: spec.chunkSize, digest: chunkDigest, server }) uploaded += spec.chunkSize - onProgress?.(uploaded, encrypted.encData.length) + onProgress?.(uploaded, total) } const rcvDescription = buildDescription("recipient", encrypted, sentChunks) const sndDescription = buildDescription("sender", encrypted, sentChunks) @@ -142,7 +157,7 @@ export async function uploadFile( function buildDescription( party: "recipient" | "sender", - enc: EncryptedFileInfo, + enc: EncryptedFileMetadata, chunks: SentChunk[] ): FileDescription { const defChunkSize = enc.chunkSizes[0] @@ -223,61 +238,111 @@ async function uploadRedirectDescription( // ── Download ──────────────────────────────────────────────────── +export interface RawDownloadedChunk { + chunkNo: number + dhSecret: Uint8Array + nonce: Uint8Array + body: Uint8Array + digest: Uint8Array +} + +export interface DownloadRawOptions { + onProgress?: (downloaded: number, total: number) => void + concurrency?: number +} + +export async function downloadFileRaw( + agent: XFTPClientAgent, + fd: FileDescription, + onRawChunk: (chunk: RawDownloadedChunk) => Promise, + options?: DownloadRawOptions +): Promise { + const err = validateFileDescription(fd) + if (err) throw new Error("downloadFileRaw: " + err) + const {onProgress, concurrency = 1} = options ?? {} + // Resolve redirect on main thread (redirect data is small) + if (fd.redirect !== null) { + fd = await resolveRedirect(agent, fd) + } + const resolvedFd = fd + // Pre-connect to avoid race condition under concurrency + const servers = new Set(resolvedFd.chunks.map(c => c.replicas[0]?.server).filter(Boolean) as string[]) + for (const s of servers) { + await getXFTPServerClient(agent, parseXFTPServer(s)) + } + // Sliding-window parallel download + let downloaded = 0 + const queue = resolvedFd.chunks.slice() + let idx = 0 + async function worker() { + while (idx < queue.length) { + const i = idx++ + const chunk = queue[i] + const replica = chunk.replicas[0] + if (!replica) throw new Error("downloadFileRaw: chunk has no replicas") + const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) + const seed = decodePrivKeyEd25519(replica.replicaKey) + const kp = ed25519KeyPairFromSeed(seed) + const raw = await downloadXFTPChunkRaw(client, kp.privateKey, replica.replicaId) + await onRawChunk({ + chunkNo: chunk.chunkNo, + dhSecret: raw.dhSecret, + nonce: raw.nonce, + body: raw.body, + digest: chunk.digest + }) + downloaded += chunk.chunkSize + onProgress?.(downloaded, resolvedFd.size) + } + } + const workers = Array.from({length: Math.min(concurrency, queue.length)}, () => worker()) + await Promise.all(workers) + return resolvedFd +} + +export async function ackFileChunks( + agent: XFTPClientAgent, fd: FileDescription +): Promise { + for (const chunk of fd.chunks) { + const replica = chunk.replicas[0] + if (!replica) continue + try { + const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) + const seed = decodePrivKeyEd25519(replica.replicaKey) + const kp = ed25519KeyPairFromSeed(seed) + await ackXFTPChunk(client, kp.privateKey, replica.replicaId) + } catch (_) {} + } +} + export async function downloadFile( agent: XFTPClientAgent, fd: FileDescription, onProgress?: (downloaded: number, total: number) => void ): Promise { - const err = validateFileDescription(fd) - if (err) throw new Error("downloadFile: " + err) - if (fd.redirect !== null) { - return downloadWithRedirect(agent, fd, onProgress) - } - const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length) - let downloaded = 0 - for (const chunk of fd.chunks) { - const replica = chunk.replicas[0] - if (!replica) throw new Error("downloadFile: chunk has no replicas") - const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) - const seed = decodePrivKeyEd25519(replica.replicaKey) - const kp = ed25519KeyPairFromSeed(seed) - const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest) - plaintextChunks[chunk.chunkNo - 1] = data - downloaded += chunk.chunkSize - onProgress?.(downloaded, fd.size) - } - // Verify file size - const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0) - if (totalSize !== fd.size) throw new Error("downloadFile: file size mismatch") - // Verify file digest (SHA-512 of encrypted file data) - const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks) + const chunks: Uint8Array[] = [] + const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => { + chunks[raw.chunkNo - 1] = decryptReceivedChunk( + raw.dhSecret, raw.nonce, raw.body, raw.digest + ) + }, {onProgress}) + const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks) + if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch") const digest = sha512(combined) - if (!digestEqual(digest, fd.digest)) throw new Error("downloadFile: file digest mismatch") - // Decrypt - const result = processDownloadedFile(fd, plaintextChunks) - // ACK all chunks (best-effort) - for (const chunk of fd.chunks) { - const replica = chunk.replicas[0] - if (!replica) continue - try { - const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) - const seed = decodePrivKeyEd25519(replica.replicaKey) - const kp = ed25519KeyPairFromSeed(seed) - await ackXFTPChunk(client, kp.privateKey, replica.replicaId) - } catch (_) {} - } + if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch") + const result = processDownloadedFile(resolvedFd, chunks) + await ackFileChunks(agent, resolvedFd) return result } -async function downloadWithRedirect( +async function resolveRedirect( agent: XFTPClientAgent, - fd: FileDescription, - onProgress?: (downloaded: number, total: number) => void -): Promise { + fd: FileDescription +): Promise { const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length) for (const chunk of fd.chunks) { const replica = chunk.replicas[0] - if (!replica) throw new Error("downloadWithRedirect: chunk has no replicas") + if (!replica) throw new Error("resolveRedirect: chunk has no replicas") const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) const seed = decodePrivKeyEd25519(replica.replicaKey) const kp = ed25519KeyPairFromSeed(seed) @@ -285,28 +350,19 @@ async function downloadWithRedirect( plaintextChunks[chunk.chunkNo - 1] = data } const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0) - if (totalSize !== fd.size) throw new Error("downloadWithRedirect: redirect file size mismatch") + if (totalSize !== fd.size) throw new Error("resolveRedirect: redirect file size mismatch") const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks) const digest = sha512(combined) - if (!digestEqual(digest, fd.digest)) throw new Error("downloadWithRedirect: redirect file digest mismatch") + if (!digestEqual(digest, fd.digest)) throw new Error("resolveRedirect: redirect file digest mismatch") const {content: yamlBytes} = processDownloadedFile(fd, plaintextChunks) const innerFd = decodeFileDescription(new TextDecoder().decode(yamlBytes)) const innerErr = validateFileDescription(innerFd) - if (innerErr) throw new Error("downloadWithRedirect: inner description invalid: " + innerErr) - if (innerFd.size !== fd.redirect!.size) throw new Error("downloadWithRedirect: redirect size mismatch") - if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("downloadWithRedirect: redirect digest mismatch") + if (innerErr) throw new Error("resolveRedirect: inner description invalid: " + innerErr) + if (innerFd.size !== fd.redirect!.size) throw new Error("resolveRedirect: redirect size mismatch") + if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("resolveRedirect: redirect digest mismatch") // ACK redirect chunks (best-effort) - for (const chunk of fd.chunks) { - const replica = chunk.replicas[0] - if (!replica) continue - try { - const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server)) - const seed = decodePrivKeyEd25519(replica.replicaKey) - const kp = ed25519KeyPairFromSeed(seed) - await ackXFTPChunk(client, kp.privateKey, replica.replicaId) - } catch (_) {} - } - return downloadFile(agent, innerFd, onProgress) + await ackFileChunks(agent, fd) + return innerFd } // ── Delete ────────────────────────────────────────────────────── diff --git a/xftp-web/src/client.ts b/xftp-web/src/client.ts index 1d57779ce..602ce646d 100644 --- a/xftp-web/src/client.ts +++ b/xftp-web/src/client.ts @@ -207,15 +207,28 @@ export async function uploadXFTPChunk( if (response.type !== "FROk") throw new Error("unexpected response: " + response.type) } -export async function downloadXFTPChunk( - c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array -): Promise { +export interface RawChunkResponse { + dhSecret: Uint8Array + nonce: Uint8Array + body: Uint8Array +} + +export async function downloadXFTPChunkRaw( + c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array +): Promise { const {publicKey, privateKey} = generateX25519KeyPair() const cmd = encodeFGET(encodePubKeyX25519(publicKey)) const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd) if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type) const dhSecret = dh(response.rcvDhKey, privateKey) - return decryptReceivedChunk(dhSecret, response.nonce, body, digest ?? null) + return {dhSecret, nonce: response.nonce, body} +} + +export async function downloadXFTPChunk( + c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array +): Promise { + const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId) + return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null) } export async function deleteXFTPChunk( diff --git a/xftp-web/src/protocol/description.ts b/xftp-web/src/protocol/description.ts index 00afd2bd9..99d8213c5 100644 --- a/xftp-web/src/protocol/description.ts +++ b/xftp-web/src/protocol/description.ts @@ -91,7 +91,7 @@ export type FileParty = "recipient" | "sender" export interface FileDescription { party: FileParty size: number // total file size in bytes - digest: Uint8Array // SHA-256 file digest + digest: Uint8Array // SHA-512 file digest key: Uint8Array // SbKey (32 bytes) nonce: Uint8Array // CbNonce (24 bytes) chunkSize: number // default chunk size in bytes diff --git a/xftp-web/test/page.spec.ts b/xftp-web/test/page.spec.ts new file mode 100644 index 000000000..bcdcd2490 --- /dev/null +++ b/xftp-web/test/page.spec.ts @@ -0,0 +1,42 @@ +import {test, expect} from '@playwright/test' + +const PAGE_URL = 'http://localhost:4173' + +test('page upload + download round-trip', async ({page}) => { + // Upload page + await page.goto(PAGE_URL) + await expect(page.locator('#drop-zone')).toBeVisible() + + // Create a small test file + const content = 'Hello SimpleX ' + Date.now() + const fileName = 'test-file.txt' + const buffer = Buffer.from(content, 'utf-8') + + // Set file via hidden input + const fileInput = page.locator('#file-input') + await fileInput.setInputFiles({name: fileName, mimeType: 'text/plain', buffer}) + + // Wait for upload to complete + const shareLink = page.locator('[data-testid="share-link"]') + await expect(shareLink).toBeVisible({timeout: 30_000}) + + // Extract the hash from the share link + const linkValue = await shareLink.inputValue() + const hash = new URL(linkValue).hash + + // Navigate to download page + await page.goto(PAGE_URL + hash) + await expect(page.locator('#dl-btn')).toBeVisible() + + // Start download and wait for completion + const downloadPromise = page.waitForEvent('download') + await page.locator('#dl-btn').click() + const download = await downloadPromise + + // Verify downloaded file + expect(download.suggestedFilename()).toBe(fileName) + const downloadedContent = (await download.path()) !== null + ? (await import('fs')).readFileSync(await download.path()!, 'utf-8') + : '' + expect(downloadedContent).toBe(content) +}) diff --git a/xftp-web/tsconfig.web.json b/xftp-web/tsconfig.web.json new file mode 100644 index 000000000..476d40b5e --- /dev/null +++ b/xftp-web/tsconfig.web.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": [], + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"] + }, + "include": ["web/**/*.ts", "src/**/*.ts"], + "exclude": ["web/crypto.worker.ts"] +} diff --git a/xftp-web/tsconfig.worker.json b/xftp-web/tsconfig.worker.json new file mode 100644 index 000000000..0335541dc --- /dev/null +++ b/xftp-web/tsconfig.worker.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": [], + "moduleResolution": "bundler", + "lib": ["ES2022", "WebWorker"] + }, + "include": ["web/crypto.worker.ts", "src/**/*.ts"] +} diff --git a/xftp-web/vite.config.ts b/xftp-web/vite.config.ts new file mode 100644 index 000000000..e2fb61c79 --- /dev/null +++ b/xftp-web/vite.config.ts @@ -0,0 +1,51 @@ +import {defineConfig, type Plugin} from 'vite' +import {readFileSync} from 'fs' +import {createHash} from 'crypto' +import presets from './web/servers.json' + +function parseHost(addr: string): string { + const m = addr.match(/@(.+)$/) + if (!m) throw new Error('bad server address: ' + addr) + const host = m[1].split(',')[0] + return host.includes(':') ? host : host + ':443' +} + +function cspPlugin(servers: string[]): Plugin { + const origins = servers.map(s => 'https://' + parseHost(s)).join(' ') + return { + name: 'csp-connect-src', + transformIndexHtml: { + order: 'pre', + handler(html, ctx) { + if (ctx.server) { + return html.replace(/]*?Content-Security-Policy[\s\S]*?>/i, '') + } + return html.replace('__CSP_CONNECT_SRC__', origins) + } + } + } +} + +export default defineConfig(({mode}) => { + const define: Record = {} + let servers: string[] + + if (mode === 'development') { + const pem = readFileSync('../tests/fixtures/ca.crt', 'utf-8') + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''), 'base64') + const fp = createHash('sha256').update(der).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_') + servers = [`xftp://${fp}@localhost:7000`] + define['__XFTP_SERVERS__'] = JSON.stringify(servers) + } else { + servers = [...presets.simplex, ...presets.flux] + } + + return { + root: 'web', + build: {outDir: '../dist-web', target: 'esnext'}, + define, + worker: {format: 'es' as const}, + plugins: [cspPlugin(servers)], + } +}) diff --git a/xftp-web/vitest.config.ts b/xftp-web/vitest.config.ts index f4b66a981..8cc87733f 100644 --- a/xftp-web/vitest.config.ts +++ b/xftp-web/vitest.config.ts @@ -9,6 +9,8 @@ const fingerprint = createHash('sha256').update(der).digest('base64').replace(/\ const serverAddr = `xftp://${fingerprint}@localhost:7000` export default defineConfig({ + esbuild: {target: 'esnext'}, + optimizeDeps: {esbuildOptions: {target: 'esnext'}}, define: { 'import.meta.env.XFTP_SERVER': JSON.stringify(serverAddr) }, diff --git a/xftp-web/web/crypto-backend.ts b/xftp-web/web/crypto-backend.ts new file mode 100644 index 000000000..8d12cd7d8 --- /dev/null +++ b/xftp-web/web/crypto-backend.ts @@ -0,0 +1,112 @@ +import type {FileHeader} from '../src/crypto/file.js' + +export interface CryptoBackend { + encrypt(data: Uint8Array, fileName: string, + onProgress?: (done: number, total: number) => void + ): Promise + readChunk(offset: number, size: number): Promise + decryptAndStoreChunk( + dhSecret: Uint8Array, nonce: Uint8Array, + body: Uint8Array, digest: Uint8Array, chunkNo: number + ): Promise + verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array} + ): Promise<{header: FileHeader, content: Uint8Array}> + cleanup(): Promise +} + +export interface EncryptResult { + digest: Uint8Array + key: Uint8Array + nonce: Uint8Array + chunkSizes: number[] +} + +type PendingRequest = {resolve: (value: any) => void, reject: (reason: any) => void} + +class WorkerBackend implements CryptoBackend { + private worker: Worker + private pending = new Map() + private nextId = 1 + private progressCb: ((done: number, total: number) => void) | null = null + + constructor() { + this.worker = new Worker(new URL('./crypto.worker.ts', import.meta.url), {type: 'module'}) + this.worker.onmessage = (e) => this.handleMessage(e.data) + } + + private handleMessage(msg: {id: number, type: string, [k: string]: any}) { + if (msg.type === 'progress') { + this.progressCb?.(msg.done, msg.total) + return + } + const p = this.pending.get(msg.id) + if (!p) return + this.pending.delete(msg.id) + if (msg.type === 'error') { + p.reject(new Error(msg.message)) + } else { + p.resolve(msg) + } + } + + private send(msg: Record, transfer?: Transferable[]): Promise { + const id = this.nextId++ + return new Promise((resolve, reject) => { + this.pending.set(id, {resolve, reject}) + this.worker.postMessage({...msg, id}, transfer ?? []) + }) + } + + private toTransferable(data: Uint8Array): ArrayBuffer { + if (data.byteOffset !== 0 || data.byteLength !== data.buffer.byteLength) { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer + } + return data.buffer as ArrayBuffer + } + + async encrypt(data: Uint8Array, fileName: string, + onProgress?: (done: number, total: number) => void): Promise { + this.progressCb = onProgress ?? null + const buf = this.toTransferable(data) + const resp = await this.send({type: 'encrypt', data: buf, fileName}, [buf]) + this.progressCb = null + return {digest: resp.digest, key: resp.key, nonce: resp.nonce, chunkSizes: resp.chunkSizes} + } + + async readChunk(offset: number, size: number): Promise { + const resp = await this.send({type: 'readChunk', offset, size}) + return new Uint8Array(resp.data) + } + + async decryptAndStoreChunk( + dhSecret: Uint8Array, nonce: Uint8Array, + body: Uint8Array, digest: Uint8Array, chunkNo: number + ): Promise { + const buf = this.toTransferable(body) + await this.send( + {type: 'decryptAndStoreChunk', dhSecret, nonce, body: buf, chunkDigest: digest, chunkNo}, + [buf] + ) + } + + async verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array} + ): Promise<{header: FileHeader, content: Uint8Array}> { + const resp = await this.send({ + type: 'verifyAndDecrypt', + size: params.size, digest: params.digest, key: params.key, nonce: params.nonce + }) + return {header: resp.header, content: new Uint8Array(resp.content)} + } + + async cleanup(): Promise { + await this.send({type: 'cleanup'}) + this.worker.terminate() + } +} + +export function createCryptoBackend(): CryptoBackend { + if (typeof Worker === 'undefined') { + throw new Error('Web Workers required — update your browser') + } + return new WorkerBackend() +} diff --git a/xftp-web/web/crypto.worker.ts b/xftp-web/web/crypto.worker.ts new file mode 100644 index 000000000..15d7b3dfe --- /dev/null +++ b/xftp-web/web/crypto.worker.ts @@ -0,0 +1,225 @@ +import sodium from 'libsodium-wrappers-sumo' +import {encryptFile, encodeFileHeader, decryptChunks} from '../src/crypto/file.js' +import {sha512} from '../src/crypto/digest.js' +import {prepareChunkSizes, fileSizeLen, authTagSize} from '../src/protocol/chunks.js' +import {concatBytes} from '../src/protocol/encoding.js' +import {decryptReceivedChunk} from '../src/download.js' + +// ── OPFS session management ───────────────────────────────────── + +const SESSION_DIR = `session-${Date.now()}-${crypto.randomUUID()}` +let uploadReadHandle: FileSystemSyncAccessHandle | null = null +let downloadWriteHandle: FileSystemSyncAccessHandle | null = null +const chunkMeta = new Map() +let currentDownloadOffset = 0 +let sessionDir: FileSystemDirectoryHandle | null = null + +async function getSessionDir(): Promise { + if (!sessionDir) { + const root = await navigator.storage.getDirectory() + sessionDir = await root.getDirectoryHandle(SESSION_DIR, {create: true}) + } + return sessionDir +} + +async function sweepStale() { + const root = await navigator.storage.getDirectory() + const oneHourAgo = Date.now() - 3600_000 + for await (const [name] of (root as any).entries()) { + if (!name.startsWith('session-')) continue + const parts = name.split('-') + const ts = parseInt(parts[1], 10) + if (!isNaN(ts) && ts < oneHourAgo) { + try { await root.removeEntry(name, {recursive: true}) } catch (_) {} + } + } +} + +// ── Message handlers ──────────────────────────────────────────── + +async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) { + const source = new Uint8Array(data) + const key = new Uint8Array(32) + const nonce = new Uint8Array(24) + crypto.getRandomValues(key) + crypto.getRandomValues(nonce) + const fileHdr = encodeFileHeader({fileName, fileExtra: null}) + const fileSize = BigInt(fileHdr.length + source.length) + const payloadSize = Number(fileSize) + fileSizeLen + authTagSize + const chunkSizes = prepareChunkSizes(payloadSize) + const encSize = BigInt(chunkSizes.reduce((a: number, b: number) => a + b, 0)) + const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize) + + self.postMessage({id, type: 'progress', done: 50, total: 100}) + + const digest = sha512(encData) + + self.postMessage({id, type: 'progress', done: 80, total: 100}) + + // Write to OPFS + const dir = await getSessionDir() + const fileHandle = await dir.getFileHandle('upload.bin', {create: true}) + const writeHandle = await fileHandle.createSyncAccessHandle() + writeHandle.write(encData) + writeHandle.flush() + writeHandle.close() + + // Reopen as persistent read handle + uploadReadHandle = await fileHandle.createSyncAccessHandle() + + self.postMessage({id, type: 'progress', done: 100, total: 100}) + self.postMessage({id, type: 'encrypted', digest, key, nonce, chunkSizes}) +} + +function handleReadChunk(id: number, offset: number, size: number) { + if (!uploadReadHandle) { + self.postMessage({id, type: 'error', message: 'No upload file open'}) + return + } + const buf = new Uint8Array(size) + uploadReadHandle.read(buf, {at: offset}) + const ab = buf.buffer as ArrayBuffer + self.postMessage({id, type: 'chunk', data: ab}, [ab]) +} + +async function handleDecryptAndStore( + id: number, dhSecret: Uint8Array, nonce: Uint8Array, + body: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number +) { + const bodyArr = new Uint8Array(body) + const decrypted = decryptReceivedChunk(dhSecret, nonce, bodyArr, chunkDigest) + + if (!downloadWriteHandle) { + const dir = await getSessionDir() + const fileHandle = await dir.getFileHandle('download.bin', {create: true}) + downloadWriteHandle = await fileHandle.createSyncAccessHandle() + } + + const offset = currentDownloadOffset + currentDownloadOffset += decrypted.length + chunkMeta.set(chunkNo, {offset, size: decrypted.length}) + downloadWriteHandle.write(decrypted, {at: offset}) + downloadWriteHandle.flush() + + self.postMessage({id, type: 'stored'}) +} + +async function handleVerifyAndDecrypt( + id: number, size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array +) { + // Close write handle, reopen as read + if (downloadWriteHandle) { + downloadWriteHandle.flush() + downloadWriteHandle.close() + downloadWriteHandle = null + } + + const dir = await getSessionDir() + const fileHandle = await dir.getFileHandle('download.bin') + const readHandle = await fileHandle.createSyncAccessHandle() + + // Read chunks ordered by chunkNo + const sortedEntries = [...chunkMeta.entries()].sort((a, b) => a[0] - b[0]) + const chunks: Uint8Array[] = [] + for (const [, meta] of sortedEntries) { + const buf = new Uint8Array(meta.size) + readHandle.read(buf, {at: meta.offset}) + chunks.push(buf) + } + readHandle.close() + + // Verify size + const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks) + if (combined.length !== size) { + self.postMessage({id, type: 'error', message: `File size mismatch: ${combined.length} !== ${size}`}) + return + } + + // Verify SHA-512 digest + const actualDigest = sha512(combined) + if (!digestEqual(actualDigest, digest)) { + self.postMessage({id, type: 'error', message: 'File digest mismatch'}) + return + } + + // File-level decrypt + const result = decryptChunks(BigInt(size), chunks, key, nonce) + + // Clean up download file + try { await dir.removeEntry('download.bin') } catch (_) {} + chunkMeta.clear() + currentDownloadOffset = 0 + + const contentBuf = result.content.buffer.slice( + result.content.byteOffset, + result.content.byteOffset + result.content.byteLength + ) + self.postMessage( + {id, type: 'decrypted', header: result.header, content: contentBuf}, + [contentBuf] + ) +} + +async function handleCleanup(id: number) { + if (uploadReadHandle) { + uploadReadHandle.close() + uploadReadHandle = null + } + if (downloadWriteHandle) { + downloadWriteHandle.close() + downloadWriteHandle = null + } + chunkMeta.clear() + currentDownloadOffset = 0 + try { + const root = await navigator.storage.getDirectory() + await root.removeEntry(SESSION_DIR, {recursive: true}) + } catch (_) {} + sessionDir = null + self.postMessage({id, type: 'cleaned'}) +} + +// ── Message dispatch ──────────────────────────────────────────── + +self.onmessage = async (e: MessageEvent) => { + const msg = e.data + try { + switch (msg.type) { + case 'encrypt': + await handleEncrypt(msg.id, msg.data, msg.fileName) + break + case 'readChunk': + handleReadChunk(msg.id, msg.offset, msg.size) + break + case 'decryptAndStoreChunk': + await handleDecryptAndStore(msg.id, msg.dhSecret, msg.nonce, msg.body, msg.chunkDigest, msg.chunkNo) + break + case 'verifyAndDecrypt': + await handleVerifyAndDecrypt(msg.id, msg.size, msg.digest, msg.key, msg.nonce) + break + case 'cleanup': + await handleCleanup(msg.id) + break + default: + self.postMessage({id: msg.id, type: 'error', message: `Unknown message type: ${msg.type}`}) + } + } catch (err: any) { + self.postMessage({id: msg.id, type: 'error', message: err?.message ?? String(err)}) + } +} + +// ── Helpers ───────────────────────────────────────────────────── + +function digestEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i] + return diff === 0 +} + +// ── Init ──────────────────────────────────────────────────────── + +;(async () => { + await sodium.ready + await sweepStale() +})() diff --git a/xftp-web/web/download.ts b/xftp-web/web/download.ts new file mode 100644 index 000000000..74c50bd7c --- /dev/null +++ b/xftp-web/web/download.ts @@ -0,0 +1,137 @@ +import {createCryptoBackend} from './crypto-backend.js' +import {createProgressRing} from './progress.js' +import { + newXFTPAgent, closeXFTPAgent, + decodeDescriptionURI, downloadFileRaw, ackFileChunks +} from '../src/agent.js' + +export function initDownload(app: HTMLElement, hash: string) { + let fd: ReturnType + try { + fd = decodeDescriptionURI(hash) + } catch (err: any) { + app.innerHTML = `

Invalid or corrupted link.

` + return + } + + const size = fd.redirect ? fd.redirect.size : fd.size + app.innerHTML = ` +
+

SimpleX File Transfer

+
+

File available (~${formatSize(size)})

+ +
+

This file is encrypted — the server never sees file contents.

+

The decryption key is in the link's hash fragment, which your browser never sends to any server.

+

For maximum security, use the SimpleX app.

+
+
+ + +
` + + const readyStage = document.getElementById('dl-ready')! + const progressStage = document.getElementById('dl-progress')! + const errorStage = document.getElementById('dl-error')! + const progressContainer = document.getElementById('dl-progress-container')! + const statusText = document.getElementById('dl-status')! + const dlBtn = document.getElementById('dl-btn')! + const errorMsg = document.getElementById('dl-error-msg')! + const retryBtn = document.getElementById('dl-retry-btn')! + + function showStage(stage: HTMLElement) { + for (const s of [readyStage, progressStage, errorStage]) s.hidden = true + stage.hidden = false + } + + function showError(msg: string) { + errorMsg.textContent = msg + showStage(errorStage) + } + + dlBtn.addEventListener('click', startDownload) + retryBtn.addEventListener('click', startDownload) + + async function startDownload() { + showStage(progressStage) + const ring = createProgressRing() + progressContainer.innerHTML = '' + progressContainer.appendChild(ring.canvas) + statusText.textContent = 'Downloading…' + + const backend = createCryptoBackend() + const agent = newXFTPAgent() + + try { + const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => { + await backend.decryptAndStoreChunk( + raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo + ) + }, { + onProgress: (downloaded, total) => { + ring.update(downloaded / total * 0.8) + }, + concurrency: 3 + }) + + statusText.textContent = 'Decrypting…' + ring.update(0.85) + + const {header, content} = await backend.verifyAndDecrypt({ + size: resolvedFd.size, + digest: resolvedFd.digest, + key: resolvedFd.key, + nonce: resolvedFd.nonce + }) + + ring.update(0.95) + + // ACK (best-effort) + ackFileChunks(agent, resolvedFd).catch(() => {}) + + // Sanitize filename and trigger browser save + const fileName = sanitizeFileName(header.fileName) + const blob = new Blob([content.buffer as ArrayBuffer]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) + + ring.update(1) + statusText.textContent = 'Download complete' + } catch (err: any) { + showError(err?.message ?? String(err)) + } finally { + await backend.cleanup().catch(() => {}) + closeXFTPAgent(agent) + } + } +} + +function sanitizeFileName(name: string): string { + let s = name + // Strip path separators + s = s.replace(/[/\\]/g, '') + // Replace null/control characters + s = s.replace(/[\x00-\x1f\x7f]/g, '_') + // Strip Unicode bidi override characters + s = s.replace(/[\u202a-\u202e\u2066-\u2069]/g, '') + // Limit length + if (s.length > 255) s = s.slice(0, 255) + return s || 'download' +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} diff --git a/xftp-web/web/index.html b/xftp-web/web/index.html new file mode 100644 index 000000000..86dfc0afb --- /dev/null +++ b/xftp-web/web/index.html @@ -0,0 +1,15 @@ + + + + + + + SimpleX File Transfer + + + +
+ + + diff --git a/xftp-web/web/main.ts b/xftp-web/web/main.ts new file mode 100644 index 000000000..afb8b91d5 --- /dev/null +++ b/xftp-web/web/main.ts @@ -0,0 +1,24 @@ +import sodium from 'libsodium-wrappers-sumo' +import {initUpload} from './upload.js' +import {initDownload} from './download.js' + +async function main() { + await sodium.ready + + const app = document.getElementById('app')! + const hash = window.location.hash.slice(1) + + if (hash) { + initDownload(app, hash) + } else { + initUpload(app) + } +} + +main().catch(err => { + const app = document.getElementById('app') + if (app) { + app.innerHTML = `

Failed to initialize: ${err.message}

` + } + console.error(err) +}) diff --git a/xftp-web/web/progress.ts b/xftp-web/web/progress.ts new file mode 100644 index 000000000..2fa292f27 --- /dev/null +++ b/xftp-web/web/progress.ts @@ -0,0 +1,52 @@ +const SIZE = 120 +const LINE_WIDTH = 8 +const RADIUS = (SIZE - LINE_WIDTH) / 2 +const CENTER = SIZE / 2 +const BG_COLOR = '#e0e0e0' +const FG_COLOR = '#3b82f6' + +export interface ProgressRing { + canvas: HTMLCanvasElement + update(fraction: number): void +} + +export function createProgressRing(): ProgressRing { + const canvas = document.createElement('canvas') + canvas.width = SIZE * devicePixelRatio + canvas.height = SIZE * devicePixelRatio + canvas.style.width = SIZE + 'px' + canvas.style.height = SIZE + 'px' + canvas.className = 'progress-ring' + const ctx = canvas.getContext('2d')! + ctx.scale(devicePixelRatio, devicePixelRatio) + + function draw(fraction: number) { + ctx.clearRect(0, 0, SIZE, SIZE) + // Background arc + ctx.beginPath() + ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI) + ctx.strokeStyle = BG_COLOR + ctx.lineWidth = LINE_WIDTH + ctx.lineCap = 'round' + ctx.stroke() + // Foreground arc + if (fraction > 0) { + ctx.beginPath() + ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction) + ctx.strokeStyle = FG_COLOR + ctx.lineWidth = LINE_WIDTH + ctx.lineCap = 'round' + ctx.stroke() + } + // Percentage text + const pct = Math.round(fraction * 100) + ctx.fillStyle = '#333' + ctx.font = '600 20px system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(pct + '%', CENTER, CENTER) + } + + draw(0) + return {canvas, update: draw} +} diff --git a/xftp-web/web/servers.json b/xftp-web/web/servers.json new file mode 100644 index 000000000..334fa5783 --- /dev/null +++ b/xftp-web/web/servers.json @@ -0,0 +1,18 @@ +{ + "simplex": [ + "xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im", + "xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im", + "xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im", + "xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im", + "xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im", + "xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im" + ], + "flux": [ + "xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com", + "xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com", + "xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com", + "xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com", + "xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com", + "xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com" + ] +} diff --git a/xftp-web/web/servers.ts b/xftp-web/web/servers.ts new file mode 100644 index 000000000..0c9c8b585 --- /dev/null +++ b/xftp-web/web/servers.ts @@ -0,0 +1,16 @@ +import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js' +import presets from './servers.json' + +declare const __XFTP_SERVERS__: string[] + +const serverAddresses: string[] = typeof __XFTP_SERVERS__ !== 'undefined' + ? __XFTP_SERVERS__ + : [...presets.simplex, ...presets.flux] + +export function getServers(): XFTPServer[] { + return serverAddresses.map(parseXFTPServer) +} + +export function pickRandomServer(servers: XFTPServer[]): XFTPServer { + return servers[Math.floor(Math.random() * servers.length)] +} diff --git a/xftp-web/web/style.css b/xftp-web/web/style.css new file mode 100644 index 000000000..3c5654a0e --- /dev/null +++ b/xftp-web/web/style.css @@ -0,0 +1,103 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #f5f5f5; + color: #333; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +#app { + width: 100%; + max-width: 480px; + padding: 16px; +} + +.card { + background: #fff; + border-radius: 12px; + padding: 32px 24px; + box-shadow: 0 1px 3px rgba(0,0,0,.1); + text-align: center; +} + +h1 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 24px; +} + +.stage { margin-top: 16px; } + +/* Drop zone */ +.drop-zone { + border: 2px dashed #ccc; + border-radius: 8px; + padding: 32px 16px; + transition: border-color .15s, background .15s; +} +.drop-zone.drag-over { + border-color: #3b82f6; + background: #eff6ff; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 10px 24px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: #fff; + font-size: .9rem; + font-weight: 500; + cursor: pointer; + transition: background .15s; +} +.btn:hover { background: #2563eb; } +.btn-secondary { background: #6b7280; } +.btn-secondary:hover { background: #4b5563; } + +/* Hints */ +.hint { color: #999; font-size: .85rem; margin-top: 8px; } +.expiry { margin-top: 12px; } + +/* Progress */ +.progress-ring { display: block; margin: 0 auto 12px; } +#upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; } + +/* Share link row */ +.link-row { + display: flex; + gap: 8px; + margin-top: 12px; +} +.link-row input { + flex: 1; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: .85rem; + background: #f9fafb; +} + +/* Messages */ +.success { color: #16a34a; font-weight: 600; } +.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; } + +/* Security note */ +.security-note { + margin-top: 20px; + padding: 12px; + background: #f0fdf4; + border-radius: 6px; + font-size: .8rem; + color: #555; + text-align: left; +} +.security-note p + p { margin-top: 6px; } +.security-note a { color: #3b82f6; text-decoration: none; } +.security-note a:hover { text-decoration: underline; } diff --git a/xftp-web/web/upload.ts b/xftp-web/web/upload.ts new file mode 100644 index 000000000..b8b05bc89 --- /dev/null +++ b/xftp-web/web/upload.ts @@ -0,0 +1,164 @@ +import {createCryptoBackend} from './crypto-backend.js' +import {getServers, pickRandomServer} from './servers.js' +import {createProgressRing} from './progress.js' +import { + newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI, + type EncryptedFileMetadata +} from '../src/agent.js' + +const MAX_SIZE = 100 * 1024 * 1024 + +export function initUpload(app: HTMLElement) { + app.innerHTML = ` +
+

SimpleX File Transfer

+
+

Drag & drop a file here

+

or

+ + +

Max 100 MB

+
+ + + +
` + + const dropZone = document.getElementById('drop-zone')! + const fileInput = document.getElementById('file-input') as HTMLInputElement + const progressStage = document.getElementById('upload-progress')! + const completeStage = document.getElementById('upload-complete')! + const errorStage = document.getElementById('upload-error')! + const progressContainer = document.getElementById('progress-container')! + const statusText = document.getElementById('upload-status')! + const cancelBtn = document.getElementById('cancel-btn')! + const shareLink = document.getElementById('share-link') as HTMLInputElement + const copyBtn = document.getElementById('copy-btn')! + const errorMsg = document.getElementById('error-msg')! + const retryBtn = document.getElementById('retry-btn')! + + let aborted = false + let pendingFile: File | null = null + + dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over') }) + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')) + dropZone.addEventListener('drop', e => { + e.preventDefault() + dropZone.classList.remove('drag-over') + const f = e.dataTransfer?.files[0] + if (f) startUpload(f) + }) + fileInput.addEventListener('change', () => { + if (fileInput.files?.[0]) startUpload(fileInput.files[0]) + }) + retryBtn.addEventListener('click', () => { + if (pendingFile) startUpload(pendingFile) + }) + + function showStage(stage: HTMLElement) { + for (const s of [dropZone, progressStage, completeStage, errorStage]) s.hidden = true + stage.hidden = false + } + + function showError(msg: string) { + errorMsg.textContent = msg + showStage(errorStage) + } + + async function startUpload(file: File) { + pendingFile = file + aborted = false + + if (file.size > MAX_SIZE) { + showError(`File too large (${formatSize(file.size)}). Maximum is 100 MB.`) + return + } + if (file.size === 0) { + showError('File is empty.') + return + } + + showStage(progressStage) + const ring = createProgressRing() + progressContainer.innerHTML = '' + progressContainer.appendChild(ring.canvas) + statusText.textContent = 'Encrypting…' + + const backend = createCryptoBackend() + const agent = newXFTPAgent() + + cancelBtn.onclick = () => { + aborted = true + backend.cleanup().catch(() => {}) + closeXFTPAgent(agent) + showStage(dropZone) + } + + try { + const fileData = new Uint8Array(await file.arrayBuffer()) + if (aborted) return + + const encrypted = await backend.encrypt(fileData, file.name, (done, total) => { + ring.update(done / total * 0.3) + }) + if (aborted) return + + statusText.textContent = 'Uploading…' + const metadata: EncryptedFileMetadata = { + digest: encrypted.digest, + key: encrypted.key, + nonce: encrypted.nonce, + chunkSizes: encrypted.chunkSizes + } + const servers = getServers() + const server = pickRandomServer(servers) + const result = await uploadFile(agent, server, metadata, { + readChunk: (off, sz) => backend.readChunk(off, sz), + onProgress: (uploaded, total) => { + ring.update(0.3 + (uploaded / total) * 0.7) + } + }) + if (aborted) return + + const url = window.location.origin + window.location.pathname + '#' + result.uri + shareLink.value = url + showStage(completeStage) + copyBtn.onclick = () => { + navigator.clipboard.writeText(url).then(() => { + copyBtn.textContent = 'Copied!' + setTimeout(() => { copyBtn.textContent = 'Copy' }, 2000) + }) + } + } catch (err: any) { + if (!aborted) showError(err?.message ?? String(err)) + } finally { + await backend.cleanup().catch(() => {}) + closeXFTPAgent(agent) + } + } +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +}