Files
simplexmq/xftp-web/web/upload.ts
sh 328d3b941a xftp-web: use XFTP server domain in share link, verify on download (#1732)
* xftp-web: use XFTP server domain in share link, verify on download

Use a random XFTP server host from the file description as the link
origin instead of the current page domain. On download, verify
that the hosting domain matches one of the servers in the description.

* xftp-web: use first description server as link origin

* xftp-web: show wrong server error in download UI instead of blank page
2026-03-11 15:59:26 +00:00

212 lines
7.9 KiB
TypeScript

import {createCryptoBackend} from './crypto-backend.js'
import {getServers} from './servers.js'
import {createProgressRing} from './progress.js'
import {t} from './i18n.js'
import {
newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI,
type EncryptedFileMetadata
} from '../src/agent.js'
import {getDescriptionServers, serverOrigin} from '../src/protocol/address.js'
import {XFTPPermanentError} from '../src/client.js'
const MAX_SIZE = 100 * 1024 * 1024
const ENCRYPT_WEIGHT = 0.15
const ENCRYPT_MIN_FILE_SIZE = 100 * 1024
const ENCRYPT_MIN_DISPLAY_MS = 1000
export function initUpload(app: HTMLElement) {
app.innerHTML = `
<div class="card">
<h1>${t('title', 'SimpleX File Transfer')}</h1>
<div id="drop-zone" class="drop-zone">
<p>${t('dropZone', 'Drag & drop a file here')}</p>
<p class="hint">${t('dropZoneHint', 'or')}</p>
<label class="btn" for="file-input">${t('chooseFile', 'Choose file')}</label>
<input id="file-input" type="file" hidden>
<p class="hint">${t('maxSizeHint', 'Max 100 MB')}</p>
</div>
<div id="upload-progress" class="stage" hidden>
<div id="progress-container"></div>
<p id="upload-status">${t('encrypting', 'Encrypting\u2026')}</p>
<button id="cancel-btn" class="btn btn-secondary">${t('cancel', 'Cancel')}</button>
</div>
<div id="upload-complete" class="stage" hidden>
<p class="success">${t('fileUploaded', 'File uploaded')}</p>
<div class="link-row">
<input id="share-link" data-testid="share-link" readonly>
<button id="copy-btn" class="btn">${t('copy', 'Copy')}</button>
</div>
<p class="hint expiry">${t('expiryHint', 'Files are typically available for 48 hours.')}</p>
<div class="security-note">
<p>${t('securityNote1', 'Your file was encrypted in the browser before upload \u2014 the server never sees file contents.')}</p>
<p>${t('securityNote2', 'The link contains the decryption key in the hash fragment, which the browser never sends to any server.')}</p>
<p>${t('securityNote3', '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">${t('retry', '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')!
const shareBtn = typeof navigator.share === 'function'
? (() => {
const btn = document.createElement('button')
btn.className = 'btn btn-secondary'
btn.textContent = t('share', 'Share')
shareLink.parentElement!.appendChild(btn)
return btn
})()
: null
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', () => {
pendingFile = null
fileInput.value = ''
showStage(dropZone)
})
function showStage(stage: HTMLElement) {
for (const s of [dropZone, progressStage, completeStage, errorStage]) s.hidden = true
stage.hidden = false
}
function showError(msg: string) {
errorMsg.innerHTML = msg
showStage(errorStage)
}
async function startUpload(file: File) {
pendingFile = file
aborted = false
if (file.size > MAX_SIZE) {
showError(t('fileTooLarge', 'File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB.').replace('%size%', formatSize(file.size)))
return
}
if (file.size === 0) {
showError(t('fileEmpty', 'File is empty.'))
return
}
showStage(progressStage)
const ring = createProgressRing()
progressContainer.innerHTML = ''
progressContainer.appendChild(ring.canvas)
const showEncrypt = file.size >= ENCRYPT_MIN_FILE_SIZE
const encryptWeight = showEncrypt ? ENCRYPT_WEIGHT : 0
statusText.textContent = showEncrypt
? t('encrypting', 'Encrypting\u2026')
: t('uploading', 'Uploading\u2026')
const backend = createCryptoBackend()
const agent = newXFTPAgent()
cancelBtn.onclick = () => {
aborted = true
ring.destroy()
backend.cleanup().catch(() => {})
closeXFTPAgent(agent)
showStage(dropZone)
}
try {
const encryptStart = performance.now()
const fileData = new Uint8Array(await file.arrayBuffer())
if (aborted) return
const encrypted = await backend.encrypt(fileData, file.name, (done, total) => {
ring.update((done / total) * encryptWeight)
})
if (aborted) return
if (showEncrypt) {
const elapsed = performance.now() - encryptStart
if (elapsed < ENCRYPT_MIN_DISPLAY_MS) {
await ring.fillTo(encryptWeight, ENCRYPT_MIN_DISPLAY_MS - elapsed)
if (aborted) return
}
statusText.textContent = t('uploading', 'Uploading\u2026')
}
const metadata: EncryptedFileMetadata = {
digest: encrypted.digest,
key: encrypted.key,
nonce: encrypted.nonce,
chunkSizes: encrypted.chunkSizes
}
const servers = getServers()
const result = await uploadFile(agent, servers, metadata, {
readChunk: (off, sz) => backend.readChunk(off, sz),
onProgress: (uploaded, total) => {
ring.update(encryptWeight + (uploaded / total) * (1 - encryptWeight))
}
})
if (aborted) return
const descServers = getDescriptionServers(result.rcvDescription)
const origin = descServers.length > 0
? serverOrigin(descServers[0])
: window.location.origin
const url = origin + window.location.pathname + '#' + result.uri
shareLink.value = url
showStage(completeStage)
app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true}))
copyBtn.onclick = () => {
navigator.clipboard.writeText(url).then(() => {
copyBtn.textContent = t('copied', 'Copied!')
setTimeout(() => { copyBtn.textContent = t('copy', 'Copy') }, 2000)
})
}
if (shareBtn) {
shareBtn.onclick = () => navigator.share({url}).catch(() => {})
}
} catch (err: any) {
if (!aborted) {
const msg = err?.message ?? String(err)
showError(msg)
// Hide retry button for permanent errors (no point retrying)
if (err instanceof XFTPPermanentError) retryBtn.hidden = true
else retryBtn.hidden = false
}
} finally {
ring.destroy()
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'
}