@shhhum/xftp-web 0.6.0 → 0.7.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/dist/client.js +7 -3
- package/dist/client.js.map +1 -1
- package/dist-web/assets/crypto.worker.js +1413 -0
- package/dist-web/assets/index.css +1 -1
- package/dist-web/assets/index.js +28 -28
- package/package.json +1 -1
- package/src/client.ts +11 -7
- package/web/crypto-backend.ts +2 -1
- package/web/crypto.worker.ts +2 -2
- package/web/download.ts +14 -12
- package/web/i18n.ts +9 -0
- package/web/main.ts +17 -6
- package/web/progress.ts +9 -5
- package/web/servers.ts +2 -1
- package/web/style.css +57 -32
- package/web/upload.ts +35 -20
- package/dist-web/crypto.worker.js +0 -1413
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -97,7 +97,7 @@ interface Transport {
|
|
|
97
97
|
|
|
98
98
|
// -- Transport implementations
|
|
99
99
|
|
|
100
|
-
const isNode = typeof globalThis.process !== "undefined" && globalThis.process.versions?.node
|
|
100
|
+
const isNode = typeof (globalThis as any).process !== "undefined" && (globalThis as any).process.versions?.node
|
|
101
101
|
|
|
102
102
|
// In development mode, use HTTP proxy to avoid self-signed cert issues in browser
|
|
103
103
|
// __XFTP_PROXY_PORT__ is injected by vite build (null in production)
|
|
@@ -112,7 +112,8 @@ async function createTransport(baseUrl: string, config: TransportConfig): Promis
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
async function createNodeTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
|
|
115
|
-
|
|
115
|
+
// @ts-ignore node:http2 unavailable in browser tsconfig
|
|
116
|
+
const http2: any = await import("node:http2")
|
|
116
117
|
const session = http2.connect(baseUrl, {rejectUnauthorized: false})
|
|
117
118
|
return {
|
|
118
119
|
async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
|
|
@@ -122,11 +123,14 @@ async function createNodeTransport(baseUrl: string, config: TransportConfig): Pr
|
|
|
122
123
|
req.close()
|
|
123
124
|
reject(Object.assign(new Error("Request timeout"), {name: "AbortError"}))
|
|
124
125
|
})
|
|
125
|
-
const chunks:
|
|
126
|
-
req.on("data", (chunk:
|
|
127
|
-
req.on("end", () =>
|
|
126
|
+
const chunks: any[] = []
|
|
127
|
+
req.on("data", (chunk: any) => chunks.push(chunk))
|
|
128
|
+
req.on("end", () => {
|
|
129
|
+
const B = (globalThis as any).Buffer
|
|
130
|
+
resolve(new Uint8Array(B.concat(chunks)))
|
|
131
|
+
})
|
|
128
132
|
req.on("error", reject)
|
|
129
|
-
req.end(
|
|
133
|
+
req.end(body)
|
|
130
134
|
})
|
|
131
135
|
},
|
|
132
136
|
close() {
|
|
@@ -149,7 +153,7 @@ function createBrowserTransport(baseUrl: string, config: TransportConfig): Trans
|
|
|
149
153
|
const resp = await fetch(effectiveUrl, {
|
|
150
154
|
method: "POST",
|
|
151
155
|
headers,
|
|
152
|
-
body,
|
|
156
|
+
body: body as any,
|
|
153
157
|
signal: controller.signal
|
|
154
158
|
})
|
|
155
159
|
if (!resp.ok) {
|
package/web/crypto-backend.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {FileHeader} from '../src/crypto/file.js'
|
|
2
|
+
import {t} from './i18n.js'
|
|
2
3
|
|
|
3
4
|
export interface CryptoBackend {
|
|
4
5
|
encrypt(data: Uint8Array, fileName: string,
|
|
@@ -134,7 +135,7 @@ class WorkerBackend implements CryptoBackend {
|
|
|
134
135
|
|
|
135
136
|
export function createCryptoBackend(): CryptoBackend {
|
|
136
137
|
if (typeof Worker === 'undefined') {
|
|
137
|
-
throw new Error('Web Workers required
|
|
138
|
+
throw new Error(t('workersRequired', 'Web Workers required \u2014 update your browser'))
|
|
138
139
|
}
|
|
139
140
|
return new WorkerBackend()
|
|
140
141
|
}
|
package/web/crypto.worker.ts
CHANGED
|
@@ -190,7 +190,7 @@ async function handleVerifyAndDecrypt(
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
// Compute per-chunk SHA-512 incrementally to find divergence point
|
|
193
|
-
const state = sodium.crypto_hash_sha512_init()
|
|
193
|
+
const state = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
|
|
194
194
|
for (let i = 0; i < chunks.length; i++) {
|
|
195
195
|
const chunk = chunks[i]
|
|
196
196
|
const SEG = 4 * 1024 * 1024
|
|
@@ -202,7 +202,7 @@ async function handleVerifyAndDecrypt(
|
|
|
202
202
|
if (!digestEqual(actualDigest, digest)) {
|
|
203
203
|
console.error(`[WORKER-DBG] DIGEST MISMATCH: expected=${_whex(digest, 64)} actual=${_whex(actualDigest, 64)} chunks=${chunks.length} totalSize=${totalSize}`)
|
|
204
204
|
// Log per-chunk incremental hash to find divergence
|
|
205
|
-
const state2 = sodium.crypto_hash_sha512_init()
|
|
205
|
+
const state2 = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
|
|
206
206
|
for (let i = 0; i < chunks.length; i++) {
|
|
207
207
|
const chunk = chunks[i]
|
|
208
208
|
const SEG = 4 * 1024 * 1024
|
package/web/download.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {createCryptoBackend} from './crypto-backend.js'
|
|
2
2
|
import {createProgressRing} from './progress.js'
|
|
3
|
+
import {t} from './i18n.js'
|
|
3
4
|
import {
|
|
4
5
|
newXFTPAgent, closeXFTPAgent,
|
|
5
6
|
decodeDescriptionURI, downloadFileRaw
|
|
@@ -11,30 +12,30 @@ export function initDownload(app: HTMLElement, hash: string) {
|
|
|
11
12
|
try {
|
|
12
13
|
fd = decodeDescriptionURI(hash)
|
|
13
14
|
} catch (err: any) {
|
|
14
|
-
app.innerHTML = `<div class="card"><p class="error"
|
|
15
|
+
app.innerHTML = `<div class="card"><p class="error">${t('invalidLink', 'Invalid or corrupted link.')}</p></div>`
|
|
15
16
|
return
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const size = fd.redirect ? fd.redirect.size : fd.size
|
|
19
20
|
app.innerHTML = `
|
|
20
21
|
<div class="card">
|
|
21
|
-
<h1
|
|
22
|
+
<h1>${t('title', 'SimpleX File Transfer')}</h1>
|
|
22
23
|
<div id="dl-ready" class="stage">
|
|
23
|
-
<p
|
|
24
|
-
<button id="dl-btn" class="btn"
|
|
24
|
+
<p>${t('fileAvailable', 'File available (~%size%)').replace('%size%', formatSize(size))}</p>
|
|
25
|
+
<button id="dl-btn" class="btn">${t('download', 'Download')}</button>
|
|
25
26
|
<div class="security-note">
|
|
26
|
-
<p
|
|
27
|
-
<p
|
|
28
|
-
<p
|
|
27
|
+
<p>${t('dlSecurityNote1', 'This file is encrypted \u2014 the server never sees file contents.')}</p>
|
|
28
|
+
<p>${t('dlSecurityNote2', 'The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server.')}</p>
|
|
29
|
+
<p>${t('dlSecurityNote3', 'For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.')}</p>
|
|
29
30
|
</div>
|
|
30
31
|
</div>
|
|
31
32
|
<div id="dl-progress" class="stage" hidden>
|
|
32
33
|
<div id="dl-progress-container"></div>
|
|
33
|
-
<p id="dl-status"
|
|
34
|
+
<p id="dl-status">${t('downloading', 'Downloading\u2026')}</p>
|
|
34
35
|
</div>
|
|
35
36
|
<div id="dl-error" class="stage" hidden>
|
|
36
37
|
<p class="error" id="dl-error-msg"></p>
|
|
37
|
-
<button id="dl-retry-btn" class="btn"
|
|
38
|
+
<button id="dl-retry-btn" class="btn">${t('retry', 'Retry')}</button>
|
|
38
39
|
</div>
|
|
39
40
|
</div>`
|
|
40
41
|
|
|
@@ -65,7 +66,7 @@ export function initDownload(app: HTMLElement, hash: string) {
|
|
|
65
66
|
const ring = createProgressRing()
|
|
66
67
|
progressContainer.innerHTML = ''
|
|
67
68
|
progressContainer.appendChild(ring.canvas)
|
|
68
|
-
statusText.textContent = 'Downloading
|
|
69
|
+
statusText.textContent = t('downloading', 'Downloading\u2026')
|
|
69
70
|
|
|
70
71
|
const backend = createCryptoBackend()
|
|
71
72
|
const agent = newXFTPAgent()
|
|
@@ -81,7 +82,7 @@ export function initDownload(app: HTMLElement, hash: string) {
|
|
|
81
82
|
}
|
|
82
83
|
})
|
|
83
84
|
|
|
84
|
-
statusText.textContent = 'Decrypting
|
|
85
|
+
statusText.textContent = t('decrypting', 'Decrypting\u2026')
|
|
85
86
|
ring.update(0.85)
|
|
86
87
|
|
|
87
88
|
const {header, content} = await backend.verifyAndDecrypt({
|
|
@@ -107,7 +108,8 @@ export function initDownload(app: HTMLElement, hash: string) {
|
|
|
107
108
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
108
109
|
|
|
109
110
|
ring.update(1)
|
|
110
|
-
statusText.textContent = 'Download complete'
|
|
111
|
+
statusText.textContent = t('downloadComplete', 'Download complete')
|
|
112
|
+
app.dispatchEvent(new CustomEvent('xftp:download-complete', {detail: {fileName}, bubbles: true}))
|
|
111
113
|
} catch (err: any) {
|
|
112
114
|
const msg = err?.message ?? String(err)
|
|
113
115
|
showError(msg)
|
package/web/i18n.ts
ADDED
package/web/main.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import sodium from 'libsodium-wrappers-sumo'
|
|
2
2
|
import {initUpload} from './upload.js'
|
|
3
3
|
import {initDownload} from './download.js'
|
|
4
|
+
import {t} from './i18n.js'
|
|
5
|
+
|
|
6
|
+
function getAppElement(): HTMLElement | null {
|
|
7
|
+
return (document.querySelector('[data-xftp-app]') as HTMLElement | null) ?? document.getElementById('app')
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
async function main() {
|
|
6
11
|
await sodium.ready
|
|
7
|
-
initApp()
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
const app = getAppElement()
|
|
14
|
+
if (!app?.hasAttribute('data-defer-init')) {
|
|
15
|
+
initApp()
|
|
16
|
+
}
|
|
17
|
+
if (!app?.hasAttribute('data-no-hashchange')) {
|
|
18
|
+
window.addEventListener('hashchange', initApp)
|
|
19
|
+
}
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
function initApp() {
|
|
14
|
-
const app =
|
|
23
|
+
const app = getAppElement()!
|
|
15
24
|
const hash = window.location.hash.slice(1)
|
|
16
25
|
|
|
17
26
|
if (hash) {
|
|
@@ -21,10 +30,12 @@ function initApp() {
|
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
;(window as any).__xftp_initApp = initApp
|
|
34
|
+
|
|
24
35
|
main().catch(err => {
|
|
25
|
-
const app =
|
|
36
|
+
const app = getAppElement()
|
|
26
37
|
if (app) {
|
|
27
|
-
app.innerHTML = `<div class="error"><p
|
|
38
|
+
app.innerHTML = `<div class="error"><p>${t('initError', 'Failed to initialize: %error%').replace('%error%', err.message)}</p></div>`
|
|
28
39
|
}
|
|
29
40
|
console.error(err)
|
|
30
41
|
})
|
package/web/progress.ts
CHANGED
|
@@ -2,8 +2,6 @@ const SIZE = 120
|
|
|
2
2
|
const LINE_WIDTH = 8
|
|
3
3
|
const RADIUS = (SIZE - LINE_WIDTH) / 2
|
|
4
4
|
const CENTER = SIZE / 2
|
|
5
|
-
const BG_COLOR = '#e0e0e0'
|
|
6
|
-
const FG_COLOR = '#3b82f6'
|
|
7
5
|
|
|
8
6
|
export interface ProgressRing {
|
|
9
7
|
canvas: HTMLCanvasElement
|
|
@@ -21,11 +19,17 @@ export function createProgressRing(): ProgressRing {
|
|
|
21
19
|
ctx.scale(devicePixelRatio, devicePixelRatio)
|
|
22
20
|
|
|
23
21
|
function draw(fraction: number) {
|
|
22
|
+
const appEl = document.querySelector('[data-xftp-app]') ?? document.getElementById('app')
|
|
23
|
+
const s = appEl ? getComputedStyle(appEl) : null
|
|
24
|
+
const bgColor = s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0'
|
|
25
|
+
const fgColor = s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6'
|
|
26
|
+
const textColor = s?.getPropertyValue('--xftp-ring-text').trim() || '#333'
|
|
27
|
+
|
|
24
28
|
ctx.clearRect(0, 0, SIZE, SIZE)
|
|
25
29
|
// Background arc
|
|
26
30
|
ctx.beginPath()
|
|
27
31
|
ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
|
|
28
|
-
ctx.strokeStyle =
|
|
32
|
+
ctx.strokeStyle = bgColor
|
|
29
33
|
ctx.lineWidth = LINE_WIDTH
|
|
30
34
|
ctx.lineCap = 'round'
|
|
31
35
|
ctx.stroke()
|
|
@@ -33,14 +37,14 @@ export function createProgressRing(): ProgressRing {
|
|
|
33
37
|
if (fraction > 0) {
|
|
34
38
|
ctx.beginPath()
|
|
35
39
|
ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
|
|
36
|
-
ctx.strokeStyle =
|
|
40
|
+
ctx.strokeStyle = fgColor
|
|
37
41
|
ctx.lineWidth = LINE_WIDTH
|
|
38
42
|
ctx.lineCap = 'round'
|
|
39
43
|
ctx.stroke()
|
|
40
44
|
}
|
|
41
45
|
// Percentage text
|
|
42
46
|
const pct = Math.round(fraction * 100)
|
|
43
|
-
ctx.fillStyle =
|
|
47
|
+
ctx.fillStyle = textColor
|
|
44
48
|
ctx.font = '600 20px system-ui, sans-serif'
|
|
45
49
|
ctx.textAlign = 'center'
|
|
46
50
|
ctx.textBaseline = 'middle'
|
package/web/servers.ts
CHANGED
|
@@ -8,5 +8,6 @@ declare const __XFTP_SERVERS__: string[]
|
|
|
8
8
|
const serverAddresses: string[] = __XFTP_SERVERS__
|
|
9
9
|
|
|
10
10
|
export function getServers(): XFTPServer[] {
|
|
11
|
-
|
|
11
|
+
const addrs = (window as any).__XFTP_SERVERS__ ?? serverAddresses
|
|
12
|
+
return addrs.map(parseXFTPServer)
|
|
12
13
|
}
|
package/web/style.css
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
body {
|
|
1
|
+
#app {
|
|
4
2
|
font-family: system-ui, -apple-system, sans-serif;
|
|
5
|
-
background: #f5f5f5;
|
|
6
3
|
color: #333;
|
|
7
|
-
min-height: 100vh;
|
|
8
|
-
display: flex;
|
|
9
|
-
align-items: center;
|
|
10
|
-
justify-content: center;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
#app {
|
|
14
4
|
width: 100%;
|
|
15
5
|
max-width: 480px;
|
|
16
6
|
padding: 16px;
|
|
7
|
+
box-sizing: border-box;
|
|
17
8
|
}
|
|
18
9
|
|
|
19
|
-
.card {
|
|
10
|
+
#app .card {
|
|
20
11
|
background: #fff;
|
|
21
12
|
border-radius: 12px;
|
|
22
13
|
padding: 32px 24px;
|
|
@@ -24,28 +15,28 @@ body {
|
|
|
24
15
|
text-align: center;
|
|
25
16
|
}
|
|
26
17
|
|
|
27
|
-
h1 {
|
|
18
|
+
#app h1 {
|
|
28
19
|
font-size: 1.25rem;
|
|
29
20
|
font-weight: 600;
|
|
30
21
|
margin-bottom: 24px;
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
.stage { margin-top: 16px; }
|
|
24
|
+
#app .stage { margin-top: 16px; }
|
|
34
25
|
|
|
35
26
|
/* Drop zone */
|
|
36
|
-
.drop-zone {
|
|
27
|
+
#app .drop-zone {
|
|
37
28
|
border: 2px dashed #ccc;
|
|
38
29
|
border-radius: 8px;
|
|
39
30
|
padding: 32px 16px;
|
|
40
31
|
transition: border-color .15s, background .15s;
|
|
41
32
|
}
|
|
42
|
-
.drop-zone.drag-over {
|
|
33
|
+
#app .drop-zone.drag-over {
|
|
43
34
|
border-color: #3b82f6;
|
|
44
35
|
background: #eff6ff;
|
|
45
36
|
}
|
|
46
37
|
|
|
47
38
|
/* Buttons */
|
|
48
|
-
.btn {
|
|
39
|
+
#app .btn {
|
|
49
40
|
display: inline-block;
|
|
50
41
|
padding: 10px 24px;
|
|
51
42
|
border: none;
|
|
@@ -57,25 +48,25 @@ h1 {
|
|
|
57
48
|
cursor: pointer;
|
|
58
49
|
transition: background .15s;
|
|
59
50
|
}
|
|
60
|
-
.btn:hover { background: #2563eb; }
|
|
61
|
-
.btn-secondary { background: #6b7280; }
|
|
62
|
-
.btn-secondary:hover { background: #4b5563; }
|
|
51
|
+
#app .btn:hover { background: #2563eb; }
|
|
52
|
+
#app .btn-secondary { background: #6b7280; }
|
|
53
|
+
#app .btn-secondary:hover { background: #4b5563; }
|
|
63
54
|
|
|
64
55
|
/* Hints */
|
|
65
|
-
.hint { color: #999; font-size: .85rem; margin-top: 8px; }
|
|
66
|
-
.expiry { margin-top: 12px; }
|
|
56
|
+
#app .hint { color: #999; font-size: .85rem; margin-top: 8px; }
|
|
57
|
+
#app .expiry { margin-top: 12px; }
|
|
67
58
|
|
|
68
59
|
/* Progress */
|
|
69
|
-
.progress-ring { display: block; margin: 0 auto 12px; }
|
|
70
|
-
#upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
|
|
60
|
+
#app .progress-ring { display: block; margin: 0 auto 12px; }
|
|
61
|
+
#app #upload-status, #app #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
|
|
71
62
|
|
|
72
63
|
/* Share link row */
|
|
73
|
-
.link-row {
|
|
64
|
+
#app .link-row {
|
|
74
65
|
display: flex;
|
|
75
66
|
gap: 8px;
|
|
76
67
|
margin-top: 12px;
|
|
77
68
|
}
|
|
78
|
-
.link-row input {
|
|
69
|
+
#app .link-row input {
|
|
79
70
|
flex: 1;
|
|
80
71
|
padding: 8px 10px;
|
|
81
72
|
border: 1px solid #ccc;
|
|
@@ -85,11 +76,11 @@ h1 {
|
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
/* Messages */
|
|
88
|
-
.success { color: #16a34a; font-weight: 600; }
|
|
89
|
-
.error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
|
|
79
|
+
#app .success { color: #16a34a; font-weight: 600; }
|
|
80
|
+
#app .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
|
|
90
81
|
|
|
91
82
|
/* Security note */
|
|
92
|
-
.security-note {
|
|
83
|
+
#app .security-note {
|
|
93
84
|
margin-top: 20px;
|
|
94
85
|
padding: 12px;
|
|
95
86
|
background: #f0fdf4;
|
|
@@ -98,6 +89,40 @@ h1 {
|
|
|
98
89
|
color: #555;
|
|
99
90
|
text-align: left;
|
|
100
91
|
}
|
|
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; }
|
|
92
|
+
#app .security-note p + p { margin-top: 6px; }
|
|
93
|
+
#app .security-note a { color: #3b82f6; text-decoration: none; }
|
|
94
|
+
#app .security-note a:hover { text-decoration: underline; }
|
|
95
|
+
|
|
96
|
+
/* ── Dark mode ─────────────────────────────────── */
|
|
97
|
+
.dark #app {
|
|
98
|
+
color: #e5e7eb;
|
|
99
|
+
--xftp-ring-bg: #374151;
|
|
100
|
+
--xftp-ring-fg: #60a5fa;
|
|
101
|
+
--xftp-ring-text: #e5e7eb;
|
|
102
|
+
}
|
|
103
|
+
.dark #app .card {
|
|
104
|
+
background: #1f2937;
|
|
105
|
+
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
106
|
+
}
|
|
107
|
+
.dark #app .drop-zone { border-color: #4b5563; }
|
|
108
|
+
.dark #app .drop-zone.drag-over {
|
|
109
|
+
border-color: #60a5fa;
|
|
110
|
+
background: rgba(59,130,246,.15);
|
|
111
|
+
}
|
|
112
|
+
.dark #app .btn-secondary { background: #4b5563; }
|
|
113
|
+
.dark #app .btn-secondary:hover { background: #374151; }
|
|
114
|
+
.dark #app .hint { color: #9ca3af; }
|
|
115
|
+
.dark #app #upload-status,
|
|
116
|
+
.dark #app #dl-status { color: #9ca3af; }
|
|
117
|
+
.dark #app .link-row input {
|
|
118
|
+
background: #374151;
|
|
119
|
+
border-color: #4b5563;
|
|
120
|
+
color: #e5e7eb;
|
|
121
|
+
}
|
|
122
|
+
.dark #app .success { color: #4ade80; }
|
|
123
|
+
.dark #app .error { color: #f87171; }
|
|
124
|
+
.dark #app .security-note {
|
|
125
|
+
background: rgba(34,197,94,.1);
|
|
126
|
+
color: #d1d5db;
|
|
127
|
+
}
|
|
128
|
+
.dark #app .security-note a { color: #60a5fa; }
|
package/web/upload.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {createCryptoBackend} from './crypto-backend.js'
|
|
2
2
|
import {getServers} from './servers.js'
|
|
3
3
|
import {createProgressRing} from './progress.js'
|
|
4
|
+
import {t} from './i18n.js'
|
|
4
5
|
import {
|
|
5
6
|
newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI,
|
|
6
7
|
type EncryptedFileMetadata
|
|
@@ -12,35 +13,35 @@ const MAX_SIZE = 100 * 1024 * 1024
|
|
|
12
13
|
export function initUpload(app: HTMLElement) {
|
|
13
14
|
app.innerHTML = `
|
|
14
15
|
<div class="card">
|
|
15
|
-
<h1
|
|
16
|
+
<h1>${t('title', 'SimpleX File Transfer')}</h1>
|
|
16
17
|
<div id="drop-zone" class="drop-zone">
|
|
17
|
-
<p
|
|
18
|
-
<p class="hint"
|
|
19
|
-
<label class="btn" for="file-input"
|
|
18
|
+
<p>${t('dropZone', 'Drag & drop a file here')}</p>
|
|
19
|
+
<p class="hint">${t('dropZoneHint', 'or')}</p>
|
|
20
|
+
<label class="btn" for="file-input">${t('chooseFile', 'Choose file')}</label>
|
|
20
21
|
<input id="file-input" type="file" hidden>
|
|
21
|
-
<p class="hint"
|
|
22
|
+
<p class="hint">${t('maxSizeHint', 'Max 100 MB')}</p>
|
|
22
23
|
</div>
|
|
23
24
|
<div id="upload-progress" class="stage" hidden>
|
|
24
25
|
<div id="progress-container"></div>
|
|
25
|
-
<p id="upload-status"
|
|
26
|
-
<button id="cancel-btn" class="btn btn-secondary"
|
|
26
|
+
<p id="upload-status">${t('encrypting', 'Encrypting\u2026')}</p>
|
|
27
|
+
<button id="cancel-btn" class="btn btn-secondary">${t('cancel', 'Cancel')}</button>
|
|
27
28
|
</div>
|
|
28
29
|
<div id="upload-complete" class="stage" hidden>
|
|
29
|
-
<p class="success"
|
|
30
|
+
<p class="success">${t('fileUploaded', 'File uploaded')}</p>
|
|
30
31
|
<div class="link-row">
|
|
31
32
|
<input id="share-link" data-testid="share-link" readonly>
|
|
32
|
-
<button id="copy-btn" class="btn"
|
|
33
|
+
<button id="copy-btn" class="btn">${t('copy', 'Copy')}</button>
|
|
33
34
|
</div>
|
|
34
|
-
<p class="hint expiry"
|
|
35
|
+
<p class="hint expiry">${t('expiryHint', 'Files are typically available for 48 hours.')}</p>
|
|
35
36
|
<div class="security-note">
|
|
36
|
-
<p
|
|
37
|
-
<p
|
|
38
|
-
<p
|
|
37
|
+
<p>${t('securityNote1', 'Your file was encrypted in the browser before upload \u2014 the server never sees file contents.')}</p>
|
|
38
|
+
<p>${t('securityNote2', 'The link contains the decryption key in the hash fragment, which the browser never sends to any server.')}</p>
|
|
39
|
+
<p>${t('securityNote3', 'For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.')}</p>
|
|
39
40
|
</div>
|
|
40
41
|
</div>
|
|
41
42
|
<div id="upload-error" class="stage" hidden>
|
|
42
43
|
<p class="error" id="error-msg"></p>
|
|
43
|
-
<button id="retry-btn" class="btn"
|
|
44
|
+
<button id="retry-btn" class="btn">${t('retry', 'Retry')}</button>
|
|
44
45
|
</div>
|
|
45
46
|
</div>`
|
|
46
47
|
|
|
@@ -57,6 +58,16 @@ export function initUpload(app: HTMLElement) {
|
|
|
57
58
|
const errorMsg = document.getElementById('error-msg')!
|
|
58
59
|
const retryBtn = document.getElementById('retry-btn')!
|
|
59
60
|
|
|
61
|
+
const shareBtn = typeof navigator.share === 'function'
|
|
62
|
+
? (() => {
|
|
63
|
+
const btn = document.createElement('button')
|
|
64
|
+
btn.className = 'btn btn-secondary'
|
|
65
|
+
btn.textContent = t('share', 'Share')
|
|
66
|
+
shareLink.parentElement!.appendChild(btn)
|
|
67
|
+
return btn
|
|
68
|
+
})()
|
|
69
|
+
: null
|
|
70
|
+
|
|
60
71
|
let aborted = false
|
|
61
72
|
let pendingFile: File | null = null
|
|
62
73
|
|
|
@@ -90,11 +101,11 @@ export function initUpload(app: HTMLElement) {
|
|
|
90
101
|
aborted = false
|
|
91
102
|
|
|
92
103
|
if (file.size > MAX_SIZE) {
|
|
93
|
-
showError(
|
|
104
|
+
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)))
|
|
94
105
|
return
|
|
95
106
|
}
|
|
96
107
|
if (file.size === 0) {
|
|
97
|
-
showError('File is empty.')
|
|
108
|
+
showError(t('fileEmpty', 'File is empty.'))
|
|
98
109
|
return
|
|
99
110
|
}
|
|
100
111
|
|
|
@@ -102,7 +113,7 @@ export function initUpload(app: HTMLElement) {
|
|
|
102
113
|
const ring = createProgressRing()
|
|
103
114
|
progressContainer.innerHTML = ''
|
|
104
115
|
progressContainer.appendChild(ring.canvas)
|
|
105
|
-
statusText.textContent = 'Encrypting
|
|
116
|
+
statusText.textContent = t('encrypting', 'Encrypting\u2026')
|
|
106
117
|
|
|
107
118
|
const backend = createCryptoBackend()
|
|
108
119
|
const agent = newXFTPAgent()
|
|
@@ -123,7 +134,7 @@ export function initUpload(app: HTMLElement) {
|
|
|
123
134
|
})
|
|
124
135
|
if (aborted) return
|
|
125
136
|
|
|
126
|
-
statusText.textContent = 'Uploading
|
|
137
|
+
statusText.textContent = t('uploading', 'Uploading\u2026')
|
|
127
138
|
const metadata: EncryptedFileMetadata = {
|
|
128
139
|
digest: encrypted.digest,
|
|
129
140
|
key: encrypted.key,
|
|
@@ -142,12 +153,16 @@ export function initUpload(app: HTMLElement) {
|
|
|
142
153
|
const url = window.location.origin + window.location.pathname + '#' + result.uri
|
|
143
154
|
shareLink.value = url
|
|
144
155
|
showStage(completeStage)
|
|
156
|
+
app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true}))
|
|
145
157
|
copyBtn.onclick = () => {
|
|
146
158
|
navigator.clipboard.writeText(url).then(() => {
|
|
147
|
-
copyBtn.textContent = 'Copied!'
|
|
148
|
-
setTimeout(() => { copyBtn.textContent = 'Copy' }, 2000)
|
|
159
|
+
copyBtn.textContent = t('copied', 'Copied!')
|
|
160
|
+
setTimeout(() => { copyBtn.textContent = t('copy', 'Copy') }, 2000)
|
|
149
161
|
})
|
|
150
162
|
}
|
|
163
|
+
if (shareBtn) {
|
|
164
|
+
shareBtn.onclick = () => navigator.share({url}).catch(() => {})
|
|
165
|
+
}
|
|
151
166
|
} catch (err: any) {
|
|
152
167
|
if (!aborted) {
|
|
153
168
|
const msg = err?.message ?? String(err)
|