mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-06 23:40:18 +00:00
webpage implementation (not tested)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-web/
|
||||
package-lock.json
|
||||
|
||||
+10
-1
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
+124
-68
@@ -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<Uint8Array>
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
agent: XFTPClientAgent,
|
||||
server: XFTPServer,
|
||||
encrypted: EncryptedFileInfo,
|
||||
onProgress?: (uploaded: number, total: number) => void,
|
||||
redirectThreshold?: number
|
||||
encrypted: EncryptedFileMetadata,
|
||||
options?: UploadOptions
|
||||
): Promise<UploadResult> {
|
||||
const {onProgress, redirectThreshold, readChunk: readChunkOpt} = options ?? {}
|
||||
const readChunk: (offset: number, size: number) => Promise<Uint8Array> = 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<void>,
|
||||
options?: DownloadRawOptions
|
||||
): Promise<FileDescription> {
|
||||
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<void> {
|
||||
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<DownloadResult> {
|
||||
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<DownloadResult> {
|
||||
fd: FileDescription
|
||||
): Promise<FileDescription> {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
+17
-4
@@ -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<Uint8Array> {
|
||||
export interface RawChunkResponse {
|
||||
dhSecret: Uint8Array
|
||||
nonce: Uint8Array
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
export async function downloadXFTPChunkRaw(
|
||||
c: XFTPClient, rpKey: Uint8Array, fId: Uint8Array
|
||||
): Promise<RawChunkResponse> {
|
||||
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<Uint8Array> {
|
||||
const {dhSecret, nonce, body} = await downloadXFTPChunkRaw(c, rpKey, fId)
|
||||
return decryptReceivedChunk(dhSecret, nonce, body, digest ?? null)
|
||||
}
|
||||
|
||||
export async function deleteXFTPChunk(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "WebWorker"]
|
||||
},
|
||||
"include": ["web/crypto.worker.ts", "src/**/*.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(/<meta\s[^>]*?Content-Security-Policy[\s\S]*?>/i, '')
|
||||
}
|
||||
return html.replace('__CSP_CONNECT_SRC__', origins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const define: Record<string, string> = {}
|
||||
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)],
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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<EncryptResult>
|
||||
readChunk(offset: number, size: number): Promise<Uint8Array>
|
||||
decryptAndStoreChunk(
|
||||
dhSecret: Uint8Array, nonce: Uint8Array,
|
||||
body: Uint8Array, digest: Uint8Array, chunkNo: number
|
||||
): Promise<void>
|
||||
verifyAndDecrypt(params: {size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array}
|
||||
): Promise<{header: FileHeader, content: Uint8Array}>
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
|
||||
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<number, PendingRequest>()
|
||||
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<string, any>, transfer?: Transferable[]): Promise<any> {
|
||||
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<EncryptResult> {
|
||||
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<Uint8Array> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
@@ -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<number, {offset: number, size: number}>()
|
||||
let currentDownloadOffset = 0
|
||||
let sessionDir: FileSystemDirectoryHandle | null = null
|
||||
|
||||
async function getSessionDir(): Promise<FileSystemDirectoryHandle> {
|
||||
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()
|
||||
})()
|
||||
@@ -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<typeof decodeDescriptionURI>
|
||||
try {
|
||||
fd = decodeDescriptionURI(hash)
|
||||
} catch (err: any) {
|
||||
app.innerHTML = `<div class="card"><p class="error">Invalid or corrupted link.</p></div>`
|
||||
return
|
||||
}
|
||||
|
||||
const size = fd.redirect ? fd.redirect.size : fd.size
|
||||
app.innerHTML = `
|
||||
<div class="card">
|
||||
<h1>SimpleX File Transfer</h1>
|
||||
<div id="dl-ready" class="stage">
|
||||
<p>File available (~${formatSize(size)})</p>
|
||||
<button id="dl-btn" class="btn">Download</button>
|
||||
<div class="security-note">
|
||||
<p>This file is encrypted — the server never sees file contents.</p>
|
||||
<p>The decryption key is in the link's hash fragment, which your browser never sends to any server.</p>
|
||||
<p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dl-progress" class="stage" hidden>
|
||||
<div id="dl-progress-container"></div>
|
||||
<p id="dl-status">Downloading…</p>
|
||||
</div>
|
||||
<div id="dl-error" class="stage" hidden>
|
||||
<p class="error" id="dl-error-msg"></p>
|
||||
<button id="dl-retry-btn" class="btn">Retry</button>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src __CSP_CONNECT_SRC__;">
|
||||
<title>SimpleX File Transfer</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = `<div class="error"><p>Failed to initialize: ${err.message}</p></div>`
|
||||
}
|
||||
console.error(err)
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 = `
|
||||
<div class="card">
|
||||
<h1>SimpleX File Transfer</h1>
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<p>Drag & drop a file here</p>
|
||||
<p class="hint">or</p>
|
||||
<label class="btn" for="file-input">Choose file</label>
|
||||
<input id="file-input" type="file" hidden>
|
||||
<p class="hint">Max 100 MB</p>
|
||||
</div>
|
||||
<div id="upload-progress" class="stage" hidden>
|
||||
<div id="progress-container"></div>
|
||||
<p id="upload-status">Encrypting…</p>
|
||||
<button id="cancel-btn" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
<div id="upload-complete" class="stage" hidden>
|
||||
<p class="success">File uploaded</p>
|
||||
<div class="link-row">
|
||||
<input id="share-link" data-testid="share-link" readonly>
|
||||
<button id="copy-btn" class="btn">Copy</button>
|
||||
</div>
|
||||
<p class="hint expiry">Files are typically available for 48 hours.</p>
|
||||
<div class="security-note">
|
||||
<p>Your file was encrypted in the browser before upload — the server never sees file contents.</p>
|
||||
<p>The link contains the decryption key in the hash fragment, which the browser never sends to any server.</p>
|
||||
<p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upload-error" class="stage" hidden>
|
||||
<p class="error" id="error-msg"></p>
|
||||
<button id="retry-btn" class="btn">Retry</button>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
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'
|
||||
}
|
||||
Reference in New Issue
Block a user