Files
sh 5a32e729e0 xftp-web: add "Upload your file" link after download completes (#1736)
* xftp-web: add "Upload your file" link after download completes

* xftp-web: call initUpload directly instead of hashchange dispatch
2026-03-13 15:01:15 +00:00

176 lines
6.4 KiB
TypeScript

import {createCryptoBackend} from './crypto-backend.js'
import {createProgressRing} from './progress.js'
import {initUpload} from './upload.js'
import {t} from './i18n.js'
import {
newXFTPAgent, closeXFTPAgent,
decodeDescriptionURI, downloadFileRaw
} from '../src/agent.js'
import {getDescriptionServers} from '../src/protocol/address.js'
import {XFTPPermanentError} from '../src/client.js'
const DECRYPT_WEIGHT = 0.15
const DECRYPT_MIN_FILE_SIZE = 100 * 1024
const DECRYPT_MIN_DISPLAY_MS = 1000
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">${t('invalidLink', 'Invalid or corrupted link.')}</p></div>`
return
}
const wrongServer = !getDescriptionServers(fd).map(s => s.host).includes(window.location.hostname)
const size = fd.redirect ? fd.redirect.size : fd.size
app.innerHTML = `
<div class="card">
<h1>${t('title', 'SimpleX File Transfer')}</h1>
<div id="dl-ready" class="stage">
<p>${t('fileAvailable', 'File available (~%size%)').replace('%size%', formatSize(size))}</p>
<button id="dl-btn" class="btn">${t('download', 'Download')}</button>
<div class="security-note">
<p>${t('dlSecurityNote1', 'This file is encrypted \u2014 the server never sees file contents.')}</p>
<p>${t('dlSecurityNote2', 'The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server.')}</p>
<p>${t('dlSecurityNote3', '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">${t('downloading', 'Downloading\u2026')}</p>
<a id="dl-upload-link" class="upload-link" hidden href="#">${t('uploadYourFile', 'Upload your file')}</a>
</div>
<div id="dl-error" class="stage" hidden>
<p class="error" id="dl-error-msg"></p>
<button id="dl-retry-btn" class="btn">${t('retry', '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')!
const uploadLink = document.getElementById('dl-upload-link')!
function showStage(stage: HTMLElement) {
for (const s of [readyStage, progressStage, errorStage]) s.hidden = true
stage.hidden = false
}
function showError(msg: string) {
errorMsg.innerHTML = msg
showStage(errorStage)
}
if (wrongServer) {
readyStage.innerHTML = `<p class="error">${t('wrongServer', 'This file is not hosted on this server.')}</p>`
return
}
dlBtn.addEventListener('click', startDownload)
retryBtn.addEventListener('click', () => showStage(readyStage))
uploadLink.addEventListener('click', (e) => {
e.preventDefault()
history.replaceState(null, '', window.location.pathname)
initUpload(app)
})
async function startDownload() {
showStage(progressStage)
const ring = createProgressRing()
progressContainer.innerHTML = ''
progressContainer.appendChild(ring.canvas)
const showDecrypt = size >= DECRYPT_MIN_FILE_SIZE
const decryptWeight = showDecrypt ? DECRYPT_WEIGHT : 0
statusText.textContent = t('downloading', 'Downloading\u2026')
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) * (1 - decryptWeight))
}
})
if (showDecrypt) {
statusText.textContent = t('decrypting', 'Decrypting\u2026')
}
const decryptStart = performance.now()
const {header, content} = await backend.verifyAndDecrypt({
size: resolvedFd.size,
digest: resolvedFd.digest,
key: resolvedFd.key,
nonce: resolvedFd.nonce
}, (done, total) => {
ring.update(Math.min(0.99, (1 - decryptWeight) + (done / total) * decryptWeight))
})
if (showDecrypt) {
const elapsed = performance.now() - decryptStart
if (elapsed < DECRYPT_MIN_DISPLAY_MS) {
await ring.fillTo(0.99, DECRYPT_MIN_DISPLAY_MS - elapsed)
}
}
// 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 = encodeURIComponent(fileName)
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
ring.update(1)
statusText.textContent = t('downloadComplete', 'Download complete')
uploadLink.hidden = false
app.dispatchEvent(new CustomEvent('xftp:download-complete', {detail: {fileName}, bubbles: true}))
} catch (err: any) {
const msg = err?.message ?? String(err)
showError(msg)
if (err instanceof XFTPPermanentError) retryBtn.hidden = true
else retryBtn.hidden = false
} finally {
ring.destroy()
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'
}