fix(web): compute hashes for uploads in chunks (#27878)

* add @noble/hashes as a dep for web

* hash files in chunks

* drop old reference to crypto in test code

* use web worker for file hashing
This commit is contained in:
Freddie Floydd
2026-04-18 00:08:46 +01:00
committed by GitHub
parent 7a86f2b7b9
commit 36ebcaf00c
5 changed files with 86 additions and 126 deletions
+1
View File
@@ -30,6 +30,7 @@
"@immich/ui": "^0.76.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0",
"@photo-sphere-viewer/core": "^5.14.0",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
-3
View File
@@ -20,9 +20,6 @@ describe('fileUploader error handling', () => {
vi.spyOn(uploadManager, 'getExtensions').mockReturnValue(['.jpg']);
uploadAssetsStore.reset();
authManager.reset();
// Stub out crypto to avoid that branch
vi.stubGlobal('crypto', undefined);
});
for (const [name, mockUser] of [
+26 -6
View File
@@ -127,6 +127,30 @@ function getDeviceAssetId(asset: File) {
return 'web' + '-' + asset.name + '-' + asset.lastModified;
}
function hashFile(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {
const worker = new Worker(new URL('$lib/workers/hash-file.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', ({ data }: MessageEvent<{ result?: string; error?: string }>) => {
worker.terminate();
if (data.error) {
reject(new Error(data.error));
} else {
resolve(data.result!);
}
});
worker.addEventListener('error', (event) => {
worker.terminate();
reject(new Error(event.message));
});
worker.postMessage(file);
});
}
type FileUploaderParams = {
assetFile: File;
albumId?: string;
@@ -165,15 +189,11 @@ async function fileUploader({
}
let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined;
if (crypto?.subtle?.digest && !authManager.isSharedLink) {
if (!authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
await tick();
try {
const bytes = await assetFile.arrayBuffer();
const hash = await crypto.subtle.digest('SHA-1', bytes);
const checksum = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const checksum = await hashFile(assetFile);
const {
results: [checkUploadResult],
+24
View File
@@ -0,0 +1,24 @@
import { sha1 } from '@noble/hashes/legacy.js';
import { bytesToHex } from '@noble/hashes/utils.js';
const HASH_CHUNK_SIZE = 5 * 1024 * 1024;
async function hashFile(file: File): Promise<string> {
const hasher = sha1.create();
for (let offset = 0; offset < file.size; offset += HASH_CHUNK_SIZE) {
const slice = file.slice(offset, Math.min(offset + HASH_CHUNK_SIZE, file.size));
const buffer = await slice.arrayBuffer();
hasher.update(new Uint8Array(buffer));
}
return bytesToHex(hasher.digest());
}
addEventListener('message', (event: MessageEvent<File>) => {
void hashFile(event.data)
.then((result) => postMessage({ result }))
.catch((error: unknown) => postMessage({ error: error instanceof Error ? error.message : String(error) }));
});