mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-03-30 14:16:00 +00:00
* 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
212 lines
7.9 KiB
TypeScript
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'
|
|
}
|