76 lines
3.6 KiB
Diff
76 lines
3.6 KiB
Diff
--- 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 <videoId>.<ext>
|
|
+ * { type: 'done', ext, sha256, expectedSha, size }
|
|
+ * → file stored as <videoId>.<ext>;
|
|
+ * 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
|