Skip to content

Commit 97773f0

Browse files
webpage implementation (not tested)
1 parent 3eee58a commit 97773f0

21 files changed

+1143
-61
lines changed

xftp-web/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
node_modules/
22
dist/
3+
dist-web/
34
package-lock.json

xftp-web/package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,24 @@
99
"postinstall": "ln -sf ../../../libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs",
1010
"build": "tsc",
1111
"test": "node --experimental-vm-modules node_modules/.bin/jest",
12-
"test:browser": "vitest --run"
12+
"test:browser": "vitest --run",
13+
"dev": "vite --mode development",
14+
"build:local": "vite build --mode development",
15+
"build:prod": "vite build --mode production",
16+
"preview": "vite preview",
17+
"preview:prod": "vite build --mode production && vite preview",
18+
"check:web": "tsc -p tsconfig.web.json --noEmit && tsc -p tsconfig.worker.json --noEmit",
19+
"test:page": "playwright test test/page.spec.ts"
1320
},
1421
"devDependencies": {
1522
"@types/libsodium-wrappers-sumo": "^0.7.8",
1623
"@types/node": "^20.0.0",
1724
"@types/pako": "^2.0.3",
1825
"@vitest/browser": "^3.0.0",
26+
"@playwright/test": "^1.50.0",
1927
"playwright": "^1.50.0",
2028
"typescript": "^5.4.0",
29+
"vite": "^6.0.0",
2130
"vitest": "^3.0.0"
2231
},
2332
"dependencies": {

xftp-web/playwright.config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {defineConfig} from '@playwright/test'
2+
3+
export default defineConfig({
4+
testDir: './test',
5+
testMatch: '**/*.spec.ts',
6+
timeout: 60_000,
7+
use: {
8+
ignoreHTTPSErrors: true,
9+
launchOptions: {
10+
args: ['--ignore-certificate-errors']
11+
}
12+
},
13+
webServer: {
14+
command: 'npx vite build --mode development && npx vite preview',
15+
url: 'http://localhost:4173',
16+
reuseExistingServer: !process.env.CI
17+
},
18+
globalSetup: './test/globalSetup.ts'
19+
})

xftp-web/src/agent.ts

Lines changed: 111 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
} from "./protocol/description.js"
1616
import type {FileInfo} from "./protocol/commands.js"
1717
import {
18-
getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk,
18+
getXFTPServerClient, createXFTPChunk, uploadXFTPChunk, downloadXFTPChunk, downloadXFTPChunkRaw,
1919
ackXFTPChunk, deleteXFTPChunk, type XFTPClientAgent
2020
} from "./client.js"
2121
export {newXFTPAgent, closeXFTPAgent, type XFTPClientAgent} from "./client.js"
22-
import {processDownloadedFile} from "./download.js"
22+
import {processDownloadedFile, decryptReceivedChunk} from "./download.js"
2323
import type {XFTPServer} from "./protocol/address.js"
2424
import {formatXFTPServer, parseXFTPServer} from "./protocol/address.js"
2525
import {concatBytes} from "./protocol/encoding.js"
@@ -38,14 +38,17 @@ interface SentChunk {
3838
server: XFTPServer
3939
}
4040

41-
export interface EncryptedFileInfo {
42-
encData: Uint8Array
41+
export interface EncryptedFileMetadata {
4342
digest: Uint8Array // SHA-512 of encData
4443
key: Uint8Array // 32B SbKey
4544
nonce: Uint8Array // 24B CbNonce
4645
chunkSizes: number[]
4746
}
4847

48+
export interface EncryptedFileInfo extends EncryptedFileMetadata {
49+
encData: Uint8Array
50+
}
51+
4952
export interface UploadResult {
5053
rcvDescription: FileDescription
5154
sndDescription: FileDescription
@@ -93,13 +96,25 @@ export function encryptFileForUpload(source: Uint8Array, fileName: string): Encr
9396

9497
const DEFAULT_REDIRECT_THRESHOLD = 400
9598

99+
export interface UploadOptions {
100+
onProgress?: (uploaded: number, total: number) => void
101+
redirectThreshold?: number
102+
readChunk?: (offset: number, size: number) => Promise<Uint8Array>
103+
}
104+
96105
export async function uploadFile(
97106
agent: XFTPClientAgent,
98107
server: XFTPServer,
99-
encrypted: EncryptedFileInfo,
100-
onProgress?: (uploaded: number, total: number) => void,
101-
redirectThreshold?: number
108+
encrypted: EncryptedFileMetadata,
109+
options?: UploadOptions
102110
): Promise<UploadResult> {
111+
const {onProgress, redirectThreshold, readChunk: readChunkOpt} = options ?? {}
112+
const readChunk: (offset: number, size: number) => Promise<Uint8Array> = readChunkOpt
113+
? readChunkOpt
114+
: ('encData' in encrypted
115+
? (off, sz) => Promise.resolve((encrypted as EncryptedFileInfo).encData.subarray(off, off + sz))
116+
: () => { throw new Error("uploadFile: readChunk required when encData is absent") })
117+
const total = encrypted.chunkSizes.reduce((a, b) => a + b, 0)
103118
const specs = prepareChunkSpecs(encrypted.chunkSizes)
104119
const client = await getXFTPServerClient(agent, server)
105120
const sentChunks: SentChunk[] = []
@@ -109,7 +124,7 @@ export async function uploadFile(
109124
const chunkNo = i + 1
110125
const sndKp = generateEd25519KeyPair()
111126
const rcvKp = generateEd25519KeyPair()
112-
const chunkData = encrypted.encData.subarray(spec.chunkOffset, spec.chunkOffset + spec.chunkSize)
127+
const chunkData = await readChunk(spec.chunkOffset, spec.chunkSize)
113128
const chunkDigest = getChunkDigest(chunkData)
114129
const fileInfo: FileInfo = {
115130
sndKey: encodePubKeyEd25519(sndKp.publicKey),
@@ -126,7 +141,7 @@ export async function uploadFile(
126141
chunkSize: spec.chunkSize, digest: chunkDigest, server
127142
})
128143
uploaded += spec.chunkSize
129-
onProgress?.(uploaded, encrypted.encData.length)
144+
onProgress?.(uploaded, total)
130145
}
131146
const rcvDescription = buildDescription("recipient", encrypted, sentChunks)
132147
const sndDescription = buildDescription("sender", encrypted, sentChunks)
@@ -142,7 +157,7 @@ export async function uploadFile(
142157

143158
function buildDescription(
144159
party: "recipient" | "sender",
145-
enc: EncryptedFileInfo,
160+
enc: EncryptedFileMetadata,
146161
chunks: SentChunk[]
147162
): FileDescription {
148163
const defChunkSize = enc.chunkSizes[0]
@@ -223,39 +238,71 @@ async function uploadRedirectDescription(
223238

224239
// ── Download ────────────────────────────────────────────────────
225240

226-
export async function downloadFile(
241+
export interface RawDownloadedChunk {
242+
chunkNo: number
243+
dhSecret: Uint8Array
244+
nonce: Uint8Array
245+
body: Uint8Array
246+
digest: Uint8Array
247+
}
248+
249+
export interface DownloadRawOptions {
250+
onProgress?: (downloaded: number, total: number) => void
251+
concurrency?: number
252+
}
253+
254+
export async function downloadFileRaw(
227255
agent: XFTPClientAgent,
228256
fd: FileDescription,
229-
onProgress?: (downloaded: number, total: number) => void
230-
): Promise<DownloadResult> {
257+
onRawChunk: (chunk: RawDownloadedChunk) => Promise<void>,
258+
options?: DownloadRawOptions
259+
): Promise<FileDescription> {
231260
const err = validateFileDescription(fd)
232-
if (err) throw new Error("downloadFile: " + err)
261+
if (err) throw new Error("downloadFileRaw: " + err)
262+
const {onProgress, concurrency = 1} = options ?? {}
263+
// Resolve redirect on main thread (redirect data is small)
233264
if (fd.redirect !== null) {
234-
return downloadWithRedirect(agent, fd, onProgress)
265+
fd = await resolveRedirect(agent, fd)
235266
}
236-
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
267+
const resolvedFd = fd
268+
// Pre-connect to avoid race condition under concurrency
269+
const servers = new Set(resolvedFd.chunks.map(c => c.replicas[0]?.server).filter(Boolean) as string[])
270+
for (const s of servers) {
271+
await getXFTPServerClient(agent, parseXFTPServer(s))
272+
}
273+
// Sliding-window parallel download
237274
let downloaded = 0
238-
for (const chunk of fd.chunks) {
239-
const replica = chunk.replicas[0]
240-
if (!replica) throw new Error("downloadFile: chunk has no replicas")
241-
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
242-
const seed = decodePrivKeyEd25519(replica.replicaKey)
243-
const kp = ed25519KeyPairFromSeed(seed)
244-
const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest)
245-
plaintextChunks[chunk.chunkNo - 1] = data
246-
downloaded += chunk.chunkSize
247-
onProgress?.(downloaded, fd.size)
275+
const queue = resolvedFd.chunks.slice()
276+
let idx = 0
277+
async function worker() {
278+
while (idx < queue.length) {
279+
const i = idx++
280+
const chunk = queue[i]
281+
const replica = chunk.replicas[0]
282+
if (!replica) throw new Error("downloadFileRaw: chunk has no replicas")
283+
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
284+
const seed = decodePrivKeyEd25519(replica.replicaKey)
285+
const kp = ed25519KeyPairFromSeed(seed)
286+
const raw = await downloadXFTPChunkRaw(client, kp.privateKey, replica.replicaId)
287+
await onRawChunk({
288+
chunkNo: chunk.chunkNo,
289+
dhSecret: raw.dhSecret,
290+
nonce: raw.nonce,
291+
body: raw.body,
292+
digest: chunk.digest
293+
})
294+
downloaded += chunk.chunkSize
295+
onProgress?.(downloaded, resolvedFd.size)
296+
}
248297
}
249-
// Verify file size
250-
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
251-
if (totalSize !== fd.size) throw new Error("downloadFile: file size mismatch")
252-
// Verify file digest (SHA-512 of encrypted file data)
253-
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
254-
const digest = sha512(combined)
255-
if (!digestEqual(digest, fd.digest)) throw new Error("downloadFile: file digest mismatch")
256-
// Decrypt
257-
const result = processDownloadedFile(fd, plaintextChunks)
258-
// ACK all chunks (best-effort)
298+
const workers = Array.from({length: Math.min(concurrency, queue.length)}, () => worker())
299+
await Promise.all(workers)
300+
return resolvedFd
301+
}
302+
303+
export async function ackFileChunks(
304+
agent: XFTPClientAgent, fd: FileDescription
305+
): Promise<void> {
259306
for (const chunk of fd.chunks) {
260307
const replica = chunk.replicas[0]
261308
if (!replica) continue
@@ -266,47 +313,56 @@ export async function downloadFile(
266313
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
267314
} catch (_) {}
268315
}
269-
return result
270316
}
271317

272-
async function downloadWithRedirect(
318+
export async function downloadFile(
273319
agent: XFTPClientAgent,
274320
fd: FileDescription,
275321
onProgress?: (downloaded: number, total: number) => void
276322
): Promise<DownloadResult> {
323+
const chunks: Uint8Array[] = []
324+
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
325+
chunks[raw.chunkNo - 1] = decryptReceivedChunk(
326+
raw.dhSecret, raw.nonce, raw.body, raw.digest
327+
)
328+
}, {onProgress})
329+
const combined = chunks.length === 1 ? chunks[0] : concatBytes(...chunks)
330+
if (combined.length !== resolvedFd.size) throw new Error("downloadFile: file size mismatch")
331+
const digest = sha512(combined)
332+
if (!digestEqual(digest, resolvedFd.digest)) throw new Error("downloadFile: file digest mismatch")
333+
const result = processDownloadedFile(resolvedFd, chunks)
334+
await ackFileChunks(agent, resolvedFd)
335+
return result
336+
}
337+
338+
async function resolveRedirect(
339+
agent: XFTPClientAgent,
340+
fd: FileDescription
341+
): Promise<FileDescription> {
277342
const plaintextChunks: Uint8Array[] = new Array(fd.chunks.length)
278343
for (const chunk of fd.chunks) {
279344
const replica = chunk.replicas[0]
280-
if (!replica) throw new Error("downloadWithRedirect: chunk has no replicas")
345+
if (!replica) throw new Error("resolveRedirect: chunk has no replicas")
281346
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
282347
const seed = decodePrivKeyEd25519(replica.replicaKey)
283348
const kp = ed25519KeyPairFromSeed(seed)
284349
const data = await downloadXFTPChunk(client, kp.privateKey, replica.replicaId, chunk.digest)
285350
plaintextChunks[chunk.chunkNo - 1] = data
286351
}
287352
const totalSize = plaintextChunks.reduce((s, c) => s + c.length, 0)
288-
if (totalSize !== fd.size) throw new Error("downloadWithRedirect: redirect file size mismatch")
353+
if (totalSize !== fd.size) throw new Error("resolveRedirect: redirect file size mismatch")
289354
const combined = plaintextChunks.length === 1 ? plaintextChunks[0] : concatBytes(...plaintextChunks)
290355
const digest = sha512(combined)
291-
if (!digestEqual(digest, fd.digest)) throw new Error("downloadWithRedirect: redirect file digest mismatch")
356+
if (!digestEqual(digest, fd.digest)) throw new Error("resolveRedirect: redirect file digest mismatch")
292357
const {content: yamlBytes} = processDownloadedFile(fd, plaintextChunks)
293358
const innerFd = decodeFileDescription(new TextDecoder().decode(yamlBytes))
294359
const innerErr = validateFileDescription(innerFd)
295-
if (innerErr) throw new Error("downloadWithRedirect: inner description invalid: " + innerErr)
296-
if (innerFd.size !== fd.redirect!.size) throw new Error("downloadWithRedirect: redirect size mismatch")
297-
if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("downloadWithRedirect: redirect digest mismatch")
360+
if (innerErr) throw new Error("resolveRedirect: inner description invalid: " + innerErr)
361+
if (innerFd.size !== fd.redirect!.size) throw new Error("resolveRedirect: redirect size mismatch")
362+
if (!digestEqual(innerFd.digest, fd.redirect!.digest)) throw new Error("resolveRedirect: redirect digest mismatch")
298363
// ACK redirect chunks (best-effort)
299-
for (const chunk of fd.chunks) {
300-
const replica = chunk.replicas[0]
301-
if (!replica) continue
302-
try {
303-
const client = await getXFTPServerClient(agent, parseXFTPServer(replica.server))
304-
const seed = decodePrivKeyEd25519(replica.replicaKey)
305-
const kp = ed25519KeyPairFromSeed(seed)
306-
await ackXFTPChunk(client, kp.privateKey, replica.replicaId)
307-
} catch (_) {}
308-
}
309-
return downloadFile(agent, innerFd, onProgress)
364+
await ackFileChunks(agent, fd)
365+
return innerFd
310366
}
311367

312368
// ── Delete ──────────────────────────────────────────────────────

xftp-web/src/client.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,28 @@ export async function uploadXFTPChunk(
207207
if (response.type !== "FROk") throw new Error("unexpected response: " + response.type)
208208
}
209209

210-
export async function downloadXFTPChunk(
211-
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
212-
): Promise<Uint8Array> {
210+
export interface RawChunkResponse {
211+
dhSecret: Uint8Array
212+
nonce: Uint8Array
213+
body: Uint8Array
214+
}
215+
216+
export async function downloadXFTPChunkRaw(
217+
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array
218+
): Promise<RawChunkResponse> {
213219
const {publicKey, privateKey} = generateX25519KeyPair()
214220
const cmd = encodeFGET(encodePubKeyX25519(publicKey))
215221
const {response, body} = await sendXFTPCommand(c, rpKey, fId, cmd)
216222
if (response.type !== "FRFile") throw new Error("unexpected response: " + response.type)
217223
const dhSecret = dh(response.rcvDhKey, privateKey)
218-
return decryptReceivedChunk(dhSecret, response.nonce, body, digest ?? null)
224+
return {dhSecret, nonce: response.nonce, body}
225+
}
226+
227+
export async function downloadXFTPChunk(
228+
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array, digest?: Uint8Array
229+
): Promise<Uint8Array> {
230+
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId)
231+
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
219232
}
220233

221234
export async function deleteXFTPChunk(

xftp-web/src/protocol/description.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export type FileParty = "recipient" | "sender"
9191
export interface FileDescription {
9292
party: FileParty
9393
size: number // total file size in bytes
94-
digest: Uint8Array // SHA-256 file digest
94+
digest: Uint8Array // SHA-512 file digest
9595
key: Uint8Array // SbKey (32 bytes)
9696
nonce: Uint8Array // CbNonce (24 bytes)
9797
chunkSize: number // default chunk size in bytes

xftp-web/test/page.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {test, expect} from '@playwright/test'
2+
3+
const PAGE_URL = 'http://localhost:4173'
4+
5+
test('page upload + download round-trip', async ({page}) => {
6+
// Upload page
7+
await page.goto(PAGE_URL)
8+
await expect(page.locator('#drop-zone')).toBeVisible()
9+
10+
// Create a small test file
11+
const content = 'Hello SimpleX ' + Date.now()
12+
const fileName = 'test-file.txt'
13+
const buffer = Buffer.from(content, 'utf-8')
14+
15+
// Set file via hidden input
16+
const fileInput = page.locator('#file-input')
17+
await fileInput.setInputFiles({name: fileName, mimeType: 'text/plain', buffer})
18+
19+
// Wait for upload to complete
20+
const shareLink = page.locator('[data-testid="share-link"]')
21+
await expect(shareLink).toBeVisible({timeout: 30_000})
22+
23+
// Extract the hash from the share link
24+
const linkValue = await shareLink.inputValue()
25+
const hash = new URL(linkValue).hash
26+
27+
// Navigate to download page
28+
await page.goto(PAGE_URL + hash)
29+
await expect(page.locator('#dl-btn')).toBeVisible()
30+
31+
// Start download and wait for completion
32+
const downloadPromise = page.waitForEvent('download')
33+
await page.locator('#dl-btn').click()
34+
const download = await downloadPromise
35+
36+
// Verify downloaded file
37+
expect(download.suggestedFilename()).toBe(fileName)
38+
const downloadedContent = (await download.path()) !== null
39+
? (await import('fs')).readFileSync(await download.path()!, 'utf-8')
40+
: ''
41+
expect(downloadedContent).toBe(content)
42+
})

0 commit comments

Comments
 (0)