@shhhum/xftp-web 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -83
- package/dist/agent.d.ts +4 -6
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +79 -143
- package/dist/agent.js.map +1 -1
- package/dist/client.d.ts +0 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -6
- package/dist/client.js.map +1 -1
- package/dist/crypto/digest.d.ts +0 -1
- package/dist/crypto/digest.d.ts.map +1 -1
- package/dist/crypto/digest.js.map +1 -1
- package/dist/crypto/file.d.ts +0 -1
- package/dist/crypto/file.d.ts.map +1 -1
- package/dist/crypto/identity.d.ts +0 -1
- package/dist/crypto/keys.d.ts +0 -1
- package/dist/crypto/keys.js +0 -1
- package/dist/crypto/keys.js.map +1 -1
- package/dist/crypto/padding.d.ts +0 -1
- package/dist/crypto/secretbox.d.ts +0 -1
- package/dist/download.d.ts +0 -1
- package/dist/protocol/address.d.ts +0 -1
- package/dist/protocol/chunks.d.ts +0 -1
- package/dist/protocol/client.d.ts +0 -1
- package/dist/protocol/commands.d.ts +1 -10
- package/dist/protocol/commands.js +1 -15
- package/dist/protocol/commands.js.map +1 -1
- package/dist/protocol/description.d.ts +0 -1
- package/dist/protocol/encoding.d.ts +0 -1
- package/dist/protocol/handshake.d.ts +0 -1
- package/dist/protocol/transmission.d.ts +0 -1
- package/dist-web/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
- package/dist-web/assets/__vite-browser-external.js +1 -0
- package/dist-web/assets/index.css +1 -0
- package/dist-web/assets/index.js +1468 -0
- package/dist-web/crypto.worker.js +1413 -0
- package/dist-web/index.html +15 -0
- package/package.json +4 -5
- package/src/agent.ts +85 -156
- package/src/client.ts +1 -8
- package/src/crypto/digest.ts +2 -2
- package/src/crypto/keys.ts +0 -1
- package/src/protocol/commands.ts +2 -22
- package/web/crypto-backend.ts +122 -0
- package/web/crypto.worker.ts +313 -0
- package/web/download.ts +140 -0
- package/web/index.html +15 -0
- package/web/main.ts +30 -0
- package/web/progress.ts +52 -0
- package/web/servers.json +18 -0
- package/web/servers.ts +16 -0
- package/web/style.css +103 -0
- package/web/upload.ts +171 -0
- package/src/index.ts +0 -4
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import sodium from 'libsodium-wrappers-sumo'
|
|
2
|
+
import {encryptFile, encodeFileHeader, decryptChunks} from '../src/crypto/file.js'
|
|
3
|
+
import {sha512Streaming} from '../src/crypto/digest.js'
|
|
4
|
+
import {prepareChunkSizes, fileSizeLen, authTagSize} from '../src/protocol/chunks.js'
|
|
5
|
+
import {decryptReceivedChunk} from '../src/download.js'
|
|
6
|
+
|
|
7
|
+
// ── OPFS session management ─────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const SESSION_DIR = `session-${Date.now()}-${crypto.randomUUID()}`
|
|
10
|
+
let uploadReadHandle: FileSystemSyncAccessHandle | null = null
|
|
11
|
+
let downloadWriteHandle: FileSystemSyncAccessHandle | null = null
|
|
12
|
+
const chunkMeta = new Map<number, {offset: number, size: number}>()
|
|
13
|
+
let currentDownloadOffset = 0
|
|
14
|
+
let sessionDir: FileSystemDirectoryHandle | null = null
|
|
15
|
+
let useMemory = false
|
|
16
|
+
const memoryChunks = new Map<number, Uint8Array>()
|
|
17
|
+
|
|
18
|
+
async function getSessionDir(): Promise<FileSystemDirectoryHandle> {
|
|
19
|
+
if (!sessionDir) {
|
|
20
|
+
const root = await navigator.storage.getDirectory()
|
|
21
|
+
sessionDir = await root.getDirectoryHandle(SESSION_DIR, {create: true})
|
|
22
|
+
}
|
|
23
|
+
return sessionDir
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function sweepStale() {
|
|
27
|
+
const root = await navigator.storage.getDirectory()
|
|
28
|
+
const oneHourAgo = Date.now() - 3600_000
|
|
29
|
+
for await (const [name] of (root as any).entries()) {
|
|
30
|
+
if (!name.startsWith('session-')) continue
|
|
31
|
+
const parts = name.split('-')
|
|
32
|
+
const ts = parseInt(parts[1], 10)
|
|
33
|
+
if (!isNaN(ts) && ts < oneHourAgo) {
|
|
34
|
+
try { await root.removeEntry(name, {recursive: true}) } catch (_) {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Message handlers ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async function handleEncrypt(id: number, data: ArrayBuffer, fileName: string) {
|
|
42
|
+
const source = new Uint8Array(data)
|
|
43
|
+
const key = new Uint8Array(32)
|
|
44
|
+
const nonce = new Uint8Array(24)
|
|
45
|
+
crypto.getRandomValues(key)
|
|
46
|
+
crypto.getRandomValues(nonce)
|
|
47
|
+
const fileHdr = encodeFileHeader({fileName, fileExtra: null})
|
|
48
|
+
const fileSize = BigInt(fileHdr.length + source.length)
|
|
49
|
+
const payloadSize = Number(fileSize) + fileSizeLen + authTagSize
|
|
50
|
+
const chunkSizes = prepareChunkSizes(payloadSize)
|
|
51
|
+
const encSize = BigInt(chunkSizes.reduce((a: number, b: number) => a + b, 0))
|
|
52
|
+
const encData = encryptFile(source, fileHdr, key, nonce, fileSize, encSize)
|
|
53
|
+
|
|
54
|
+
self.postMessage({id, type: 'progress', done: 50, total: 100})
|
|
55
|
+
|
|
56
|
+
const digest = sha512Streaming([encData])
|
|
57
|
+
console.log(`[WORKER-DBG] encrypt: encData.len=${encData.length} digest=${_whex(digest, 64)} chunkSizes=[${chunkSizes.join(',')}]`)
|
|
58
|
+
|
|
59
|
+
self.postMessage({id, type: 'progress', done: 80, total: 100})
|
|
60
|
+
|
|
61
|
+
// Write to OPFS
|
|
62
|
+
const dir = await getSessionDir()
|
|
63
|
+
const fileHandle = await dir.getFileHandle('upload.bin', {create: true})
|
|
64
|
+
const writeHandle = await fileHandle.createSyncAccessHandle()
|
|
65
|
+
const written = writeHandle.write(encData)
|
|
66
|
+
if (written !== encData.length) throw new Error(`OPFS upload write: ${written}/${encData.length}`)
|
|
67
|
+
writeHandle.flush()
|
|
68
|
+
writeHandle.close()
|
|
69
|
+
|
|
70
|
+
// Reopen as persistent read handle
|
|
71
|
+
uploadReadHandle = await fileHandle.createSyncAccessHandle()
|
|
72
|
+
|
|
73
|
+
self.postMessage({id, type: 'progress', done: 100, total: 100})
|
|
74
|
+
self.postMessage({id, type: 'encrypted', digest, key, nonce, chunkSizes})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleReadChunk(id: number, offset: number, size: number) {
|
|
78
|
+
if (!uploadReadHandle) {
|
|
79
|
+
self.postMessage({id, type: 'error', message: 'No upload file open'})
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const buf = new Uint8Array(size)
|
|
83
|
+
uploadReadHandle.read(buf, {at: offset})
|
|
84
|
+
const ab = buf.buffer as ArrayBuffer
|
|
85
|
+
self.postMessage({id, type: 'chunk', data: ab}, [ab])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleDecryptAndStore(
|
|
89
|
+
id: number, dhSecret: Uint8Array, nonce: Uint8Array,
|
|
90
|
+
body: ArrayBuffer, chunkDigest: Uint8Array, chunkNo: number
|
|
91
|
+
) {
|
|
92
|
+
const bodyArr = new Uint8Array(body)
|
|
93
|
+
console.log(`[WORKER-DBG] store chunk=${chunkNo} body.len=${bodyArr.length} nonce=${_whex(nonce, 24)} dhSecret=${_whex(dhSecret)} digest=${_whex(chunkDigest, 32)} body[0..8]=${_whex(bodyArr)} body[-8..]=${_whex(bodyArr.slice(-8))}`)
|
|
94
|
+
const decrypted = decryptReceivedChunk(dhSecret, nonce, bodyArr, chunkDigest)
|
|
95
|
+
console.log(`[WORKER-DBG] decrypted chunk=${chunkNo} len=${decrypted.length} [0..8]=${_whex(decrypted)} [-8..]=${_whex(decrypted.slice(-8))}`)
|
|
96
|
+
|
|
97
|
+
if (useMemory) {
|
|
98
|
+
memoryChunks.set(chunkNo, decrypted)
|
|
99
|
+
self.postMessage({id, type: 'stored'})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!downloadWriteHandle) {
|
|
104
|
+
const dir = await getSessionDir()
|
|
105
|
+
const fileHandle = await dir.getFileHandle('download.bin', {create: true})
|
|
106
|
+
downloadWriteHandle = await fileHandle.createSyncAccessHandle()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const offset = currentDownloadOffset
|
|
110
|
+
currentDownloadOffset += decrypted.length
|
|
111
|
+
chunkMeta.set(chunkNo, {offset, size: decrypted.length})
|
|
112
|
+
const written = downloadWriteHandle.write(decrypted, {at: offset})
|
|
113
|
+
console.log(`[WORKER-DBG] OPFS write chunk=${chunkNo} offset=${offset} size=${decrypted.length} written=${written}`)
|
|
114
|
+
|
|
115
|
+
if (written !== decrypted.length) {
|
|
116
|
+
console.warn(`[WORKER] OPFS write failed chunk=${chunkNo}: ${written}/${decrypted.length}, falling back to in-memory storage`)
|
|
117
|
+
// Migrate previously written chunks from OPFS to memory
|
|
118
|
+
for (const [cn, meta] of chunkMeta.entries()) {
|
|
119
|
+
if (cn === chunkNo) continue
|
|
120
|
+
const buf = new Uint8Array(meta.size)
|
|
121
|
+
downloadWriteHandle.read(buf, {at: meta.offset})
|
|
122
|
+
memoryChunks.set(cn, buf)
|
|
123
|
+
}
|
|
124
|
+
downloadWriteHandle.close()
|
|
125
|
+
downloadWriteHandle = null
|
|
126
|
+
try {
|
|
127
|
+
const dir = await getSessionDir()
|
|
128
|
+
await dir.removeEntry('download.bin')
|
|
129
|
+
} catch (_) {}
|
|
130
|
+
chunkMeta.clear()
|
|
131
|
+
currentDownloadOffset = 0
|
|
132
|
+
memoryChunks.set(chunkNo, decrypted)
|
|
133
|
+
useMemory = true
|
|
134
|
+
self.postMessage({id, type: 'stored'})
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
downloadWriteHandle.flush()
|
|
139
|
+
|
|
140
|
+
// Verify: read back and compare first/last 8 bytes
|
|
141
|
+
const verifyBuf = new Uint8Array(Math.min(8, decrypted.length))
|
|
142
|
+
downloadWriteHandle.read(verifyBuf, {at: offset})
|
|
143
|
+
const verifyEnd = new Uint8Array(Math.min(8, decrypted.length))
|
|
144
|
+
downloadWriteHandle.read(verifyEnd, {at: offset + decrypted.length - verifyEnd.length})
|
|
145
|
+
console.log(`[WORKER-DBG] OPFS verify chunk=${chunkNo} readBack[0..8]=${_whex(verifyBuf)} readBack[-8..]=${_whex(verifyEnd)} expected[0..8]=${_whex(decrypted)} expected[-8..]=${_whex(decrypted.slice(-8))}`)
|
|
146
|
+
|
|
147
|
+
self.postMessage({id, type: 'stored'})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function handleVerifyAndDecrypt(
|
|
151
|
+
id: number, size: number, digest: Uint8Array, key: Uint8Array, nonce: Uint8Array
|
|
152
|
+
) {
|
|
153
|
+
console.log(`[WORKER-DBG] verify: expectedSize=${size} expectedDigest=${_whex(digest, 64)} useMemory=${useMemory} chunkMeta.size=${chunkMeta.size} memoryChunks.size=${memoryChunks.size}`)
|
|
154
|
+
|
|
155
|
+
// Read chunks — from memory (fallback) or OPFS
|
|
156
|
+
const chunks: Uint8Array[] = []
|
|
157
|
+
let totalSize = 0
|
|
158
|
+
if (useMemory) {
|
|
159
|
+
const sorted = [...memoryChunks.entries()].sort((a, b) => a[0] - b[0])
|
|
160
|
+
for (const [chunkNo, data] of sorted) {
|
|
161
|
+
console.log(`[WORKER-DBG] verify memory chunk=${chunkNo} size=${data.length}`)
|
|
162
|
+
chunks.push(data)
|
|
163
|
+
totalSize += data.length
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Close write handle, reopen as read
|
|
167
|
+
if (downloadWriteHandle) {
|
|
168
|
+
downloadWriteHandle.flush()
|
|
169
|
+
downloadWriteHandle.close()
|
|
170
|
+
downloadWriteHandle = null
|
|
171
|
+
}
|
|
172
|
+
const dir = await getSessionDir()
|
|
173
|
+
const fileHandle = await dir.getFileHandle('download.bin')
|
|
174
|
+
const readHandle = await fileHandle.createSyncAccessHandle()
|
|
175
|
+
console.log(`[WORKER-DBG] verify: OPFS file size=${readHandle.getSize()}`)
|
|
176
|
+
const sortedEntries = [...chunkMeta.entries()].sort((a, b) => a[0] - b[0])
|
|
177
|
+
for (const [chunkNo, meta] of sortedEntries) {
|
|
178
|
+
const buf = new Uint8Array(meta.size)
|
|
179
|
+
const bytesRead = readHandle.read(buf, {at: meta.offset})
|
|
180
|
+
console.log(`[WORKER-DBG] verify read chunk=${chunkNo} offset=${meta.offset} size=${meta.size} bytesRead=${bytesRead} [0..8]=${_whex(buf)} [-8..]=${_whex(buf.slice(-8))}`)
|
|
181
|
+
chunks.push(buf)
|
|
182
|
+
totalSize += meta.size
|
|
183
|
+
}
|
|
184
|
+
readHandle.close()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (totalSize !== size) {
|
|
188
|
+
self.postMessage({id, type: 'error', message: `File size mismatch: ${totalSize} !== ${size}`})
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Compute per-chunk SHA-512 incrementally to find divergence point
|
|
193
|
+
const state = sodium.crypto_hash_sha512_init()
|
|
194
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
195
|
+
const chunk = chunks[i]
|
|
196
|
+
const SEG = 4 * 1024 * 1024
|
|
197
|
+
for (let off = 0; off < chunk.length; off += SEG) {
|
|
198
|
+
sodium.crypto_hash_sha512_update(state, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const actualDigest = sodium.crypto_hash_sha512_final(state)
|
|
202
|
+
if (!digestEqual(actualDigest, digest)) {
|
|
203
|
+
console.error(`[WORKER-DBG] DIGEST MISMATCH: expected=${_whex(digest, 64)} actual=${_whex(actualDigest, 64)} chunks=${chunks.length} totalSize=${totalSize}`)
|
|
204
|
+
// Log per-chunk incremental hash to find divergence
|
|
205
|
+
const state2 = sodium.crypto_hash_sha512_init()
|
|
206
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
207
|
+
const chunk = chunks[i]
|
|
208
|
+
const SEG = 4 * 1024 * 1024
|
|
209
|
+
for (let off = 0; off < chunk.length; off += SEG) {
|
|
210
|
+
sodium.crypto_hash_sha512_update(state2, chunk.subarray(off, Math.min(off + SEG, chunk.length)))
|
|
211
|
+
}
|
|
212
|
+
// snapshot incremental hash (create temp copy of state)
|
|
213
|
+
const chunkDigest = sha512Streaming([chunk])
|
|
214
|
+
console.error(`[WORKER-DBG] chunk[${i}] size=${chunk.length} sha512=${_whex(chunkDigest, 32)}… [0..8]=${_whex(chunk)} [-8..]=${_whex(chunk.slice(-8))}`)
|
|
215
|
+
}
|
|
216
|
+
self.postMessage({id, type: 'error', message: 'File digest mismatch'})
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
console.log(`[WORKER-DBG] verify: digest OK`)
|
|
220
|
+
|
|
221
|
+
// File-level decrypt
|
|
222
|
+
const result = decryptChunks(BigInt(size), chunks, key, nonce)
|
|
223
|
+
|
|
224
|
+
// Clean up download state
|
|
225
|
+
if (!useMemory) {
|
|
226
|
+
const dir = await getSessionDir()
|
|
227
|
+
try { await dir.removeEntry('download.bin') } catch (_) {}
|
|
228
|
+
}
|
|
229
|
+
chunkMeta.clear()
|
|
230
|
+
memoryChunks.clear()
|
|
231
|
+
currentDownloadOffset = 0
|
|
232
|
+
useMemory = false
|
|
233
|
+
|
|
234
|
+
const contentBuf = result.content.buffer.slice(
|
|
235
|
+
result.content.byteOffset,
|
|
236
|
+
result.content.byteOffset + result.content.byteLength
|
|
237
|
+
)
|
|
238
|
+
self.postMessage(
|
|
239
|
+
{id, type: 'decrypted', header: result.header, content: contentBuf},
|
|
240
|
+
[contentBuf]
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function handleCleanup(id: number) {
|
|
245
|
+
if (uploadReadHandle) {
|
|
246
|
+
uploadReadHandle.close()
|
|
247
|
+
uploadReadHandle = null
|
|
248
|
+
}
|
|
249
|
+
if (downloadWriteHandle) {
|
|
250
|
+
downloadWriteHandle.close()
|
|
251
|
+
downloadWriteHandle = null
|
|
252
|
+
}
|
|
253
|
+
chunkMeta.clear()
|
|
254
|
+
memoryChunks.clear()
|
|
255
|
+
currentDownloadOffset = 0
|
|
256
|
+
useMemory = false
|
|
257
|
+
try {
|
|
258
|
+
const root = await navigator.storage.getDirectory()
|
|
259
|
+
await root.removeEntry(SESSION_DIR, {recursive: true})
|
|
260
|
+
} catch (_) {}
|
|
261
|
+
sessionDir = null
|
|
262
|
+
self.postMessage({id, type: 'cleaned'})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Message dispatch ────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
self.onmessage = async (e: MessageEvent) => {
|
|
268
|
+
await initPromise
|
|
269
|
+
const msg = e.data
|
|
270
|
+
try {
|
|
271
|
+
switch (msg.type) {
|
|
272
|
+
case 'encrypt':
|
|
273
|
+
await handleEncrypt(msg.id, msg.data, msg.fileName)
|
|
274
|
+
break
|
|
275
|
+
case 'readChunk':
|
|
276
|
+
handleReadChunk(msg.id, msg.offset, msg.size)
|
|
277
|
+
break
|
|
278
|
+
case 'decryptAndStoreChunk':
|
|
279
|
+
await handleDecryptAndStore(msg.id, msg.dhSecret, msg.nonce, msg.body, msg.chunkDigest, msg.chunkNo)
|
|
280
|
+
break
|
|
281
|
+
case 'verifyAndDecrypt':
|
|
282
|
+
await handleVerifyAndDecrypt(msg.id, msg.size, msg.digest, msg.key, msg.nonce)
|
|
283
|
+
break
|
|
284
|
+
case 'cleanup':
|
|
285
|
+
await handleCleanup(msg.id)
|
|
286
|
+
break
|
|
287
|
+
default:
|
|
288
|
+
self.postMessage({id: msg.id, type: 'error', message: `Unknown message type: ${msg.type}`})
|
|
289
|
+
}
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
self.postMessage({id: msg.id, type: 'error', message: err?.message ?? String(err)})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
function _whex(b: Uint8Array, n = 8): string {
|
|
298
|
+
return Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, '0')).join('')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function digestEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
302
|
+
if (a.length !== b.length) return false
|
|
303
|
+
let diff = 0
|
|
304
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
|
|
305
|
+
return diff === 0
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Init ────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
const initPromise = (async () => {
|
|
311
|
+
await sodium.ready
|
|
312
|
+
await sweepStale()
|
|
313
|
+
})()
|
package/web/download.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {createCryptoBackend} from './crypto-backend.js'
|
|
2
|
+
import {createProgressRing} from './progress.js'
|
|
3
|
+
import {
|
|
4
|
+
newXFTPAgent, closeXFTPAgent,
|
|
5
|
+
decodeDescriptionURI, downloadFileRaw
|
|
6
|
+
} from '../src/agent.js'
|
|
7
|
+
import {XFTPPermanentError} from '../src/client.js'
|
|
8
|
+
|
|
9
|
+
export function initDownload(app: HTMLElement, hash: string) {
|
|
10
|
+
let fd: ReturnType<typeof decodeDescriptionURI>
|
|
11
|
+
try {
|
|
12
|
+
fd = decodeDescriptionURI(hash)
|
|
13
|
+
} catch (err: any) {
|
|
14
|
+
app.innerHTML = `<div class="card"><p class="error">Invalid or corrupted link.</p></div>`
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const size = fd.redirect ? fd.redirect.size : fd.size
|
|
19
|
+
app.innerHTML = `
|
|
20
|
+
<div class="card">
|
|
21
|
+
<h1>SimpleX File Transfer</h1>
|
|
22
|
+
<div id="dl-ready" class="stage">
|
|
23
|
+
<p>File available (~${formatSize(size)})</p>
|
|
24
|
+
<button id="dl-btn" class="btn">Download</button>
|
|
25
|
+
<div class="security-note">
|
|
26
|
+
<p>This file is encrypted — the server never sees file contents.</p>
|
|
27
|
+
<p>The decryption key is in the link's hash fragment, which your browser never sends to any server.</p>
|
|
28
|
+
<p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div id="dl-progress" class="stage" hidden>
|
|
32
|
+
<div id="dl-progress-container"></div>
|
|
33
|
+
<p id="dl-status">Downloading…</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div id="dl-error" class="stage" hidden>
|
|
36
|
+
<p class="error" id="dl-error-msg"></p>
|
|
37
|
+
<button id="dl-retry-btn" class="btn">Retry</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>`
|
|
40
|
+
|
|
41
|
+
const readyStage = document.getElementById('dl-ready')!
|
|
42
|
+
const progressStage = document.getElementById('dl-progress')!
|
|
43
|
+
const errorStage = document.getElementById('dl-error')!
|
|
44
|
+
const progressContainer = document.getElementById('dl-progress-container')!
|
|
45
|
+
const statusText = document.getElementById('dl-status')!
|
|
46
|
+
const dlBtn = document.getElementById('dl-btn')!
|
|
47
|
+
const errorMsg = document.getElementById('dl-error-msg')!
|
|
48
|
+
const retryBtn = document.getElementById('dl-retry-btn')!
|
|
49
|
+
|
|
50
|
+
function showStage(stage: HTMLElement) {
|
|
51
|
+
for (const s of [readyStage, progressStage, errorStage]) s.hidden = true
|
|
52
|
+
stage.hidden = false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function showError(msg: string) {
|
|
56
|
+
errorMsg.textContent = msg
|
|
57
|
+
showStage(errorStage)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dlBtn.addEventListener('click', startDownload)
|
|
61
|
+
retryBtn.addEventListener('click', startDownload)
|
|
62
|
+
|
|
63
|
+
async function startDownload() {
|
|
64
|
+
showStage(progressStage)
|
|
65
|
+
const ring = createProgressRing()
|
|
66
|
+
progressContainer.innerHTML = ''
|
|
67
|
+
progressContainer.appendChild(ring.canvas)
|
|
68
|
+
statusText.textContent = 'Downloading…'
|
|
69
|
+
|
|
70
|
+
const backend = createCryptoBackend()
|
|
71
|
+
const agent = newXFTPAgent()
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const resolvedFd = await downloadFileRaw(agent, fd, async (raw) => {
|
|
75
|
+
await backend.decryptAndStoreChunk(
|
|
76
|
+
raw.dhSecret, raw.nonce, raw.body, raw.digest, raw.chunkNo
|
|
77
|
+
)
|
|
78
|
+
}, {
|
|
79
|
+
onProgress: (downloaded, total) => {
|
|
80
|
+
ring.update(downloaded / total * 0.8)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
statusText.textContent = 'Decrypting…'
|
|
85
|
+
ring.update(0.85)
|
|
86
|
+
|
|
87
|
+
const {header, content} = await backend.verifyAndDecrypt({
|
|
88
|
+
size: resolvedFd.size,
|
|
89
|
+
digest: resolvedFd.digest,
|
|
90
|
+
key: resolvedFd.key,
|
|
91
|
+
nonce: resolvedFd.nonce
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
ring.update(0.95)
|
|
95
|
+
|
|
96
|
+
// Sanitize filename and trigger browser save
|
|
97
|
+
const fileName = sanitizeFileName(header.fileName)
|
|
98
|
+
const blob = new Blob([content.buffer as ArrayBuffer])
|
|
99
|
+
const url = URL.createObjectURL(blob)
|
|
100
|
+
const a = document.createElement('a')
|
|
101
|
+
a.href = url
|
|
102
|
+
a.download = encodeURIComponent(fileName)
|
|
103
|
+
a.style.display = 'none'
|
|
104
|
+
document.body.appendChild(a)
|
|
105
|
+
a.click()
|
|
106
|
+
document.body.removeChild(a)
|
|
107
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
108
|
+
|
|
109
|
+
ring.update(1)
|
|
110
|
+
statusText.textContent = 'Download complete'
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
const msg = err?.message ?? String(err)
|
|
113
|
+
showError(msg)
|
|
114
|
+
if (err instanceof XFTPPermanentError) retryBtn.hidden = true
|
|
115
|
+
else retryBtn.hidden = false
|
|
116
|
+
} finally {
|
|
117
|
+
await backend.cleanup().catch(() => {})
|
|
118
|
+
closeXFTPAgent(agent)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeFileName(name: string): string {
|
|
124
|
+
let s = name
|
|
125
|
+
// Strip path separators
|
|
126
|
+
s = s.replace(/[/\\]/g, '')
|
|
127
|
+
// Replace null/control characters
|
|
128
|
+
s = s.replace(/[\x00-\x1f\x7f]/g, '_')
|
|
129
|
+
// Strip Unicode bidi override characters
|
|
130
|
+
s = s.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
|
|
131
|
+
// Limit length
|
|
132
|
+
if (s.length > 255) s = s.slice(0, 255)
|
|
133
|
+
return s || 'download'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatSize(bytes: number): string {
|
|
137
|
+
if (bytes < 1024) return bytes + ' B'
|
|
138
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
139
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
|
140
|
+
}
|
package/web/index.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta http-equiv="Content-Security-Policy"
|
|
7
|
+
content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' __CSP_CONNECT_SRC__;">
|
|
8
|
+
<title>SimpleX File Transfer</title>
|
|
9
|
+
<link rel="stylesheet" href="./style.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
<script type="module" src="./main.ts"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
package/web/main.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import sodium from 'libsodium-wrappers-sumo'
|
|
2
|
+
import {initUpload} from './upload.js'
|
|
3
|
+
import {initDownload} from './download.js'
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
await sodium.ready
|
|
7
|
+
initApp()
|
|
8
|
+
|
|
9
|
+
// Handle hash changes (SPA navigation)
|
|
10
|
+
window.addEventListener('hashchange', initApp)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function initApp() {
|
|
14
|
+
const app = document.getElementById('app')!
|
|
15
|
+
const hash = window.location.hash.slice(1)
|
|
16
|
+
|
|
17
|
+
if (hash) {
|
|
18
|
+
initDownload(app, hash)
|
|
19
|
+
} else {
|
|
20
|
+
initUpload(app)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
main().catch(err => {
|
|
25
|
+
const app = document.getElementById('app')
|
|
26
|
+
if (app) {
|
|
27
|
+
app.innerHTML = `<div class="error"><p>Failed to initialize: ${err.message}</p></div>`
|
|
28
|
+
}
|
|
29
|
+
console.error(err)
|
|
30
|
+
})
|
package/web/progress.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const SIZE = 120
|
|
2
|
+
const LINE_WIDTH = 8
|
|
3
|
+
const RADIUS = (SIZE - LINE_WIDTH) / 2
|
|
4
|
+
const CENTER = SIZE / 2
|
|
5
|
+
const BG_COLOR = '#e0e0e0'
|
|
6
|
+
const FG_COLOR = '#3b82f6'
|
|
7
|
+
|
|
8
|
+
export interface ProgressRing {
|
|
9
|
+
canvas: HTMLCanvasElement
|
|
10
|
+
update(fraction: number): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createProgressRing(): ProgressRing {
|
|
14
|
+
const canvas = document.createElement('canvas')
|
|
15
|
+
canvas.width = SIZE * devicePixelRatio
|
|
16
|
+
canvas.height = SIZE * devicePixelRatio
|
|
17
|
+
canvas.style.width = SIZE + 'px'
|
|
18
|
+
canvas.style.height = SIZE + 'px'
|
|
19
|
+
canvas.className = 'progress-ring'
|
|
20
|
+
const ctx = canvas.getContext('2d')!
|
|
21
|
+
ctx.scale(devicePixelRatio, devicePixelRatio)
|
|
22
|
+
|
|
23
|
+
function draw(fraction: number) {
|
|
24
|
+
ctx.clearRect(0, 0, SIZE, SIZE)
|
|
25
|
+
// Background arc
|
|
26
|
+
ctx.beginPath()
|
|
27
|
+
ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
|
|
28
|
+
ctx.strokeStyle = BG_COLOR
|
|
29
|
+
ctx.lineWidth = LINE_WIDTH
|
|
30
|
+
ctx.lineCap = 'round'
|
|
31
|
+
ctx.stroke()
|
|
32
|
+
// Foreground arc
|
|
33
|
+
if (fraction > 0) {
|
|
34
|
+
ctx.beginPath()
|
|
35
|
+
ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
|
|
36
|
+
ctx.strokeStyle = FG_COLOR
|
|
37
|
+
ctx.lineWidth = LINE_WIDTH
|
|
38
|
+
ctx.lineCap = 'round'
|
|
39
|
+
ctx.stroke()
|
|
40
|
+
}
|
|
41
|
+
// Percentage text
|
|
42
|
+
const pct = Math.round(fraction * 100)
|
|
43
|
+
ctx.fillStyle = '#333'
|
|
44
|
+
ctx.font = '600 20px system-ui, sans-serif'
|
|
45
|
+
ctx.textAlign = 'center'
|
|
46
|
+
ctx.textBaseline = 'middle'
|
|
47
|
+
ctx.fillText(pct + '%', CENTER, CENTER)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
draw(0)
|
|
51
|
+
return {canvas, update: draw}
|
|
52
|
+
}
|
package/web/servers.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"simplex": [
|
|
3
|
+
"xftp://da1aH3nOT-9G8lV7bWamhxpDYdJ1xmW7j3JpGaDR5Ug=@xftp1.simplex.im",
|
|
4
|
+
"xftp://5vog2Imy1ExJB_7zDZrkV1KDWi96jYFyy9CL6fndBVw=@xftp2.simplex.im",
|
|
5
|
+
"xftp://PYa32DdYNFWi0uZZOprWQoQpIk5qyjRJ3EF7bVpbsn8=@xftp3.simplex.im",
|
|
6
|
+
"xftp://k_GgQl40UZVV0Y4BX9ZTyMVqX5ZewcLW0waQIl7AYDE=@xftp4.simplex.im",
|
|
7
|
+
"xftp://-bIo6o8wuVc4wpZkZD3tH-rCeYaeER_0lz1ffQcSJDs=@xftp5.simplex.im",
|
|
8
|
+
"xftp://6nSvtY9pJn6PXWTAIMNl95E1Kk1vD7FM2TeOA64CFLg=@xftp6.simplex.im"
|
|
9
|
+
],
|
|
10
|
+
"flux": [
|
|
11
|
+
"xftp://92Sctlc09vHl_nAqF2min88zKyjdYJ9mgxRCJns5K2U=@xftp1.simplexonflux.com",
|
|
12
|
+
"xftp://YBXy4f5zU1CEhnbbCzVWTNVNsaETcAGmYqGNxHntiE8=@xftp2.simplexonflux.com",
|
|
13
|
+
"xftp://ARQO74ZSvv2OrulRF3CdgwPz_AMy27r0phtLSq5b664=@xftp3.simplexonflux.com",
|
|
14
|
+
"xftp://ub2jmAa9U0uQCy90O-fSUNaYCj6sdhl49Jh3VpNXP58=@xftp4.simplexonflux.com",
|
|
15
|
+
"xftp://Rh19D5e4Eez37DEE9hAlXDB3gZa1BdFYJTPgJWPO9OI=@xftp5.simplexonflux.com",
|
|
16
|
+
"xftp://0AznwoyfX8Od9T_acp1QeeKtxUi676IBIiQjXVwbdyU=@xftp6.simplexonflux.com"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/web/servers.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {parseXFTPServer, type XFTPServer} from '../src/protocol/address.js'
|
|
2
|
+
|
|
3
|
+
// __XFTP_SERVERS__ is injected at build time by vite.config.ts
|
|
4
|
+
// In development mode: test server from globalSetup
|
|
5
|
+
// In production mode: preset servers from servers.json
|
|
6
|
+
declare const __XFTP_SERVERS__: string[]
|
|
7
|
+
|
|
8
|
+
const serverAddresses: string[] = __XFTP_SERVERS__
|
|
9
|
+
|
|
10
|
+
export function getServers(): XFTPServer[] {
|
|
11
|
+
return serverAddresses.map(parseXFTPServer)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function pickRandomServer(servers: XFTPServer[]): XFTPServer {
|
|
15
|
+
return servers[Math.floor(Math.random() * servers.length)]
|
|
16
|
+
}
|
package/web/style.css
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
body {
|
|
4
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
5
|
+
background: #f5f5f5;
|
|
6
|
+
color: #333;
|
|
7
|
+
min-height: 100vh;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#app {
|
|
14
|
+
width: 100%;
|
|
15
|
+
max-width: 480px;
|
|
16
|
+
padding: 16px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.card {
|
|
20
|
+
background: #fff;
|
|
21
|
+
border-radius: 12px;
|
|
22
|
+
padding: 32px 24px;
|
|
23
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.1);
|
|
24
|
+
text-align: center;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
h1 {
|
|
28
|
+
font-size: 1.25rem;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
margin-bottom: 24px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.stage { margin-top: 16px; }
|
|
34
|
+
|
|
35
|
+
/* Drop zone */
|
|
36
|
+
.drop-zone {
|
|
37
|
+
border: 2px dashed #ccc;
|
|
38
|
+
border-radius: 8px;
|
|
39
|
+
padding: 32px 16px;
|
|
40
|
+
transition: border-color .15s, background .15s;
|
|
41
|
+
}
|
|
42
|
+
.drop-zone.drag-over {
|
|
43
|
+
border-color: #3b82f6;
|
|
44
|
+
background: #eff6ff;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Buttons */
|
|
48
|
+
.btn {
|
|
49
|
+
display: inline-block;
|
|
50
|
+
padding: 10px 24px;
|
|
51
|
+
border: none;
|
|
52
|
+
border-radius: 6px;
|
|
53
|
+
background: #3b82f6;
|
|
54
|
+
color: #fff;
|
|
55
|
+
font-size: .9rem;
|
|
56
|
+
font-weight: 500;
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
transition: background .15s;
|
|
59
|
+
}
|
|
60
|
+
.btn:hover { background: #2563eb; }
|
|
61
|
+
.btn-secondary { background: #6b7280; }
|
|
62
|
+
.btn-secondary:hover { background: #4b5563; }
|
|
63
|
+
|
|
64
|
+
/* Hints */
|
|
65
|
+
.hint { color: #999; font-size: .85rem; margin-top: 8px; }
|
|
66
|
+
.expiry { margin-top: 12px; }
|
|
67
|
+
|
|
68
|
+
/* Progress */
|
|
69
|
+
.progress-ring { display: block; margin: 0 auto 12px; }
|
|
70
|
+
#upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
|
|
71
|
+
|
|
72
|
+
/* Share link row */
|
|
73
|
+
.link-row {
|
|
74
|
+
display: flex;
|
|
75
|
+
gap: 8px;
|
|
76
|
+
margin-top: 12px;
|
|
77
|
+
}
|
|
78
|
+
.link-row input {
|
|
79
|
+
flex: 1;
|
|
80
|
+
padding: 8px 10px;
|
|
81
|
+
border: 1px solid #ccc;
|
|
82
|
+
border-radius: 6px;
|
|
83
|
+
font-size: .85rem;
|
|
84
|
+
background: #f9fafb;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Messages */
|
|
88
|
+
.success { color: #16a34a; font-weight: 600; }
|
|
89
|
+
.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
|
|
90
|
+
|
|
91
|
+
/* Security note */
|
|
92
|
+
.security-note {
|
|
93
|
+
margin-top: 20px;
|
|
94
|
+
padding: 12px;
|
|
95
|
+
background: #f0fdf4;
|
|
96
|
+
border-radius: 6px;
|
|
97
|
+
font-size: .8rem;
|
|
98
|
+
color: #555;
|
|
99
|
+
text-align: left;
|
|
100
|
+
}
|
|
101
|
+
.security-note p + p { margin-top: 6px; }
|
|
102
|
+
.security-note a { color: #3b82f6; text-decoration: none; }
|
|
103
|
+
.security-note a:hover { text-decoration: underline; }
|