|
13 | 13 | import { pg } from '$lib/postguard' |
14 | 14 |
|
15 | 15 | import YiviQRCode from '$lib/components/filesharing/YiviQRCode.svelte' |
| 16 | + import DecryptionProgress from '$lib/components/filesharing/DecryptionProgress.svelte' |
16 | 17 | import Chip from '$lib/components/Chip.svelte' |
17 | 18 | import { isMobile } from '$lib/browser-detect' |
18 | 19 |
|
|
44 | 45 | let { rightMode = $bindable(), readable, uuid, recipient } = $props() |
45 | 46 |
|
46 | 47 | let opened = $state() |
| 48 | + /** @type {number | undefined} */ |
| 49 | + let decryptPct = $state(undefined) |
47 | 50 |
|
48 | 51 | function checkRecipients() { |
49 | 52 | const recipients = [...policies.keys()].filter((k) => k) |
|
86 | 89 | } |
87 | 90 |
|
88 | 91 | async function startDecryption() { |
| 92 | + decryptPct = undefined |
89 | 93 | try { |
90 | 94 | const result = await opened.decrypt({ |
91 | 95 | element: '#yivi-fallback', |
92 | 96 | recipient: key, |
93 | 97 | enableCache: true, |
| 98 | + /** @param {number | undefined} pct */ |
| 99 | + onDownloadProgress: (pct) => { |
| 100 | + if (decryptState !== STATES.Decrypting) { |
| 101 | + decryptState = STATES.Decrypting |
| 102 | + } |
| 103 | + decryptPct = pct |
| 104 | + }, |
94 | 105 | }) |
95 | 106 |
|
96 | | - decryptState = STATES.Decrypting |
97 | | -
|
98 | | - // pg-js wraps `data:`-mode uploads as a single-file zip |
99 | | - // (`data.bin` = the raw bytes) before encrypting, because the |
100 | | - // upload pipeline always works on Files. uuid-mode decrypts |
101 | | - // therefore yield DecryptFileResult{blob, files} and not |
102 | | - // DecryptDataResult{plaintext}. Unwrap the zip here so the |
103 | | - // downstream parseMail sees real MIME bytes. |
104 | | - const plaintext = result.plaintext |
105 | | - ? result.plaintext |
106 | | - : await extractFromZip(result.blob, 'data.bin') |
| 107 | + // pg-js auto-unwraps `data:`-mode payloads (single-entry zip |
| 108 | + // with `data.bin`) into DecryptDataResult.plaintext. Email |
| 109 | + // fallback uploads are always `data:` mode, so this branch is |
| 110 | + // the expected one. If we ever receive a multi-file result |
| 111 | + // here, fall back to the first entry's bytes. |
| 112 | + const plaintext = |
| 113 | + result.plaintext ?? |
| 114 | + new Uint8Array(await result.files[0].blob.arrayBuffer()) |
107 | 115 |
|
108 | 116 | const outStream = new TextDecoder().decode(plaintext) |
109 | 117 | decryptedMail = await email.parseMail(outStream) |
|
114 | 122 | } |
115 | 123 | } |
116 | 124 |
|
117 | | - /** Extract a single file from a ZIP blob and return its uncompressed |
118 | | - * bytes. Reads via the central directory (where conflux — pg-js's zip |
119 | | - * writer — records the real sizes) because the local file headers in |
120 | | - * streaming-mode zips carry zeros. Supports stored (method 0) and |
121 | | - * deflate (method 8); DecompressionStream('deflate-raw') is the right |
122 | | - * decoder for ZIP-embedded deflate. */ |
123 | | - async function extractFromZip(blob, filename) { |
124 | | - const buf = await blob.arrayBuffer() |
125 | | - const view = new DataView(buf) |
126 | | - const bytes = new Uint8Array(buf) |
127 | | - const decoder = new TextDecoder('utf-8') |
128 | | -
|
129 | | - // Find EOCD (End of Central Directory): signature 0x06054b50, |
130 | | - // located in the last ~64 KB of the file. |
131 | | - let eocdOffset = -1 |
132 | | - for ( |
133 | | - let i = bytes.length - 22; |
134 | | - i >= Math.max(0, bytes.length - 65557); |
135 | | - i-- |
136 | | - ) { |
137 | | - if (view.getUint32(i, true) === 0x06054b50) { |
138 | | - eocdOffset = i |
139 | | - break |
140 | | - } |
141 | | - } |
142 | | - if (eocdOffset === -1) throw new Error('ZIP EOCD record not found') |
143 | | -
|
144 | | - const cdOffset = view.getUint32(eocdOffset + 16, true) |
145 | | - const numEntries = view.getUint16(eocdOffset + 10, true) |
146 | | -
|
147 | | - let pos = cdOffset |
148 | | - for (let i = 0; i < numEntries; i++) { |
149 | | - if (view.getUint32(pos, true) !== 0x02014b50) break // CDR signature |
150 | | -
|
151 | | - const method = view.getUint16(pos + 10, true) |
152 | | - const compressedSize = view.getUint32(pos + 20, true) |
153 | | - const nameLen = view.getUint16(pos + 28, true) |
154 | | - const extraLen = view.getUint16(pos + 30, true) |
155 | | - const commentLen = view.getUint16(pos + 32, true) |
156 | | - const lfhOffset = view.getUint32(pos + 42, true) |
157 | | - const name = decoder.decode( |
158 | | - bytes.slice(pos + 46, pos + 46 + nameLen) |
159 | | - ) |
160 | | -
|
161 | | - if (name === filename) { |
162 | | - // Re-parse the local file header to find the data start; |
163 | | - // its name + extra fields can differ in length from the |
164 | | - // CDR entry. |
165 | | - const lfhNameLen = view.getUint16(lfhOffset + 26, true) |
166 | | - const lfhExtraLen = view.getUint16(lfhOffset + 28, true) |
167 | | - const dataStart = lfhOffset + 30 + lfhNameLen + lfhExtraLen |
168 | | - const compressed = bytes.slice( |
169 | | - dataStart, |
170 | | - dataStart + compressedSize |
171 | | - ) |
172 | | -
|
173 | | - if (method === 0) return compressed |
174 | | - if (method === 8) { |
175 | | - const stream = new Blob([compressed]) |
176 | | - .stream() |
177 | | - .pipeThrough(new DecompressionStream('deflate-raw')) |
178 | | - return new Uint8Array( |
179 | | - await new Response(stream).arrayBuffer() |
180 | | - ) |
181 | | - } |
182 | | - throw new Error( |
183 | | - `Unsupported zip compression method ${method} for ${filename}` |
184 | | - ) |
185 | | - } |
186 | | -
|
187 | | - pos += 46 + nameLen + extraLen + commentLen |
188 | | - } |
189 | | -
|
190 | | - throw new Error(`File "${filename}" not found in zip`) |
191 | | - } |
192 | | -
|
193 | 125 | async function storeMail(unparsed) { |
194 | 126 | const hash = await decrypt.digestMessage(unparsed) |
195 | 127 | const found = $emails.find((e) => e.hash === hash) |
|
321 | 253 | /> |
322 | 254 | </div> |
323 | 255 | {:else if decryptState === STATES.Decrypting} |
324 | | - <div class="spinner-wrapper"> |
325 | | - <svg class="spinner" viewBox="0 0 24 24" width="36" height="36"> |
326 | | - <circle |
327 | | - class="spinner-circle" |
328 | | - cx="12" |
329 | | - cy="12" |
330 | | - r="10" |
331 | | - fill="none" |
332 | | - stroke="currentColor" |
333 | | - stroke-width="3" |
334 | | - ></circle> |
335 | | - </svg> |
336 | | - </div> |
337 | | - <p class="status-text"> |
338 | | - {$_('fallback.decrypt.decrypting', { default: 'Decrypting...' })} |
339 | | - </p> |
| 256 | + <DecryptionProgress percentage={decryptPct} /> |
340 | 257 | {:else if decryptState === STATES.Fail} |
341 | 258 | <div class="decrypt-card error-card"> |
342 | 259 | <p class="error-text"> |
|
0 commit comments