--- a/frontend/opfs-worker.js +++ b/frontend/opfs-worker.js @@ -13,12 +13,20 @@ * Out messages: * { type: 'unsupported' } → caller falls back to main thread * { type: 'progress', received } → bytes written so far - * { type: 'done', ext } → file stored as . + * { type: 'done', ext, sha256, expectedSha, size } + * → file stored as .; + * sha256 = hash of the stored bytes + * (P2P content id), expectedSha = the + * server's X-Content-SHA256 or null * { type: 'error', error } → failed; .part cleaned up * ========================================================================== */ 'use strict'; +// Incremental SHA-256 (frontend/sha256.js): the file is hashed while it is +// written, so the device knows its content id without re-reading the file. +try { importScripts('/sha256.js'); } catch { /* hashing unavailable — save still works */ } + async function getVideosDir() { const root = await navigator.storage.getDirectory(); return root.getDirectoryHandle('videos', { create: true }); @@ -60,6 +68,7 @@ dir = await getVideosDir(); const partHandle = await dir.getFileHandle(partName, { create: true }); const access = await partHandle.createSyncAccessHandle(); + const hasher = self.Sha256 ? self.Sha256.create() : null; let offset = 0; try { const reader = res.body.getReader(); @@ -67,6 +76,7 @@ const { done, value } = await reader.read(); if (done) break; access.write(value, { at: offset }); + if (hasher) hasher.update(value); offset += value.byteLength; self.postMessage({ type: 'progress', received: offset }); } @@ -81,6 +91,14 @@ if (expected > 0 && offset !== expected) { throw new Error(`download cut short (${offset} of ${expected} bytes)`); } + // The server names the hash of what it sent (media cache copies). A + // mismatch means the bytes were damaged on the way — never keep them. + const sha256 = hasher ? hasher.hex() : null; + const sent = (res.headers.get('x-content-sha256') || '').trim().toLowerCase(); + const expectedSha = /^[0-9a-f]{64}$/.test(sent) ? sent : null; + if (sha256 && expectedSha && sha256 !== expectedSha) { + throw new Error('integrity check failed (content hash mismatch)'); + } // Finalize: .part → permanent name. Prefer the native rename, but treat // ANY move() failure as "unavailable" and fall back to a chunked copy — @@ -111,7 +129,7 @@ await dir.removeEntry(partName); } - self.postMessage({ type: 'done', ext }); + self.postMessage({ type: 'done', ext, sha256, expectedSha, size: offset }); } catch (err) { // Never leave a corrupt partial behind try { if (dir && partName) await dir.removeEntry(partName); } catch { /* gone */ } --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -136,7 +136,7 @@ }; worker.onmessage = (e) => { const m = e.data || {}; - if (m.type === 'done') finish({ ok: true }); + if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 }); else if (m.type === 'unsupported') finish({ ok: false, fallback: true }); else if (m.type === 'error') finish({ ok: false, error: m.error }); // 'progress' messages are informational; ignored here