-
-
Notifications
You must be signed in to change notification settings - Fork 104
Expand file tree
/
Copy pathupload.ts
More file actions
220 lines (202 loc) · 8.77 KB
/
Copy pathupload.ts
File metadata and controls
220 lines (202 loc) · 8.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
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'
//Maximum allowed upload size
const MAX_SIZE = 100 * 1024 * 1024
//How much progress bar reserved for encryption phase.
const ENCRYPT_WEIGHT = 0.15
// Only show encryption UI for files large enough to notice.
const ENCRYPT_MIN_FILE_SIZE = 100 * 1024
// Keep encryption progress visible long enough to feel smooth.
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')!
//“ If the browser supports the native Web Share API, dynamically create and insert a Share button and store its reference; otherwise keep it null.”
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
//aborted tracks whether the current async upload operation should stop, while pendingFile stores the currently selected file being processed or prepared for upload.
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)
})
// This app currently supports single file upload
fileInput.addEventListener('change', () => {
if (fileInput.files?.[0]) startUpload(fileInput.files[0])
})
retryBtn.addEventListener('click', () => {
pendingFile = null
fileInput.value = ''
showStage(dropZone)
})
//“Hide every application stage, then make only the requested stage visible.”
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)
//adds the visual progress ring to the page,
// decides whether encryption progress should be shown based on file size,
// reserves part of the progress bar for encryption work, and updates the UI status text accordingly.
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'
}