Files
Oxicloud/static/js/features/files/instantUpload.js
T

178 lines
6.3 KiB
JavaScript
Raw Normal View History

/**
* OxiCloud - Instant upload (zero-byte dedup upload)
*
* Before transferring a file's bytes, compute its BLAKE3 locally (in a
* worker, off the main thread) and ask the server whether the caller
* already owns that exact content (`GET /api/dedup/check/{hash}` — the
* check is user-scoped, never a global content oracle). On a hit, a
* single metadata call (`POST /api/files/by-hash`) registers the file
* with ZERO content bytes on the wire.
*
* Performance posture:
* - Hashing runs in a dedicated worker with WASM SIMD128 — the UI thread
* never blocks, RAM stays constant (8 MiB slices).
* - Files below {@link INSTANT_UPLOAD_MIN_SIZE} skip the whole dance:
* two extra round-trips cost more than just uploading them.
* - Any failure (no WASM support, worker error, server miss, races)
* falls back silently to the normal byte upload — instant upload is
* an optimization, never a gate.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
/**
* Files smaller than this upload normally: hashing + two round-trips
* outweigh the transfer. 8 MiB matches the chunked-upload threshold's
* order of magnitude.
*/
export const INSTANT_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
// Absolute URL on purpose — works in dev and in the release IIFE bundle
// (same pattern as the pdf.js loader in thumbnail.js).
const HASH_WORKER_URL = '/js/workers/hashWorker.js';
/** Hashing budget: 60 s base + 30 s per GB (WASM SIMD does ~0.5-1 GB/s). */
const HASH_TIMEOUT_BASE_MS = 60000;
const HASH_TIMEOUT_PER_GB_MS = 30000;
/**
* `false` once the environment proved unable to run the worker/WASM
* (old browser, blocked worker) — later files skip straight to the byte
* upload instead of failing the same way again. `null` = not yet known.
* @type {boolean | null}
*/
let _instantUploadUsable = null;
/**
* Hash a file in a one-shot worker. Resolves `null` on any failure —
* the caller falls back to a normal upload.
* @param {File} file
* @returns {Promise<string | null>}
*/
function hashFileInWorker(file) {
return new Promise((resolve) => {
/** @type {Worker} */
let worker;
try {
worker = new Worker(HASH_WORKER_URL, { type: 'module' });
} catch (_) {
_instantUploadUsable = false;
resolve(null);
return;
}
const sizeGB = file.size / (1024 * 1024 * 1024);
const timeoutMs = HASH_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * HASH_TIMEOUT_PER_GB_MS;
/** @param {string | null} hash */
const settle = (hash) => {
clearTimeout(timer);
worker.terminate();
resolve(hash);
};
const timer = setTimeout(() => settle(null), timeoutMs);
worker.onmessage = (event) => {
const data = /** @type {{ ok: boolean, hash?: string, error?: string }} */ (event.data);
if (!data.ok) {
// The worker ran but WASM failed (e.g. no SIMD128 support):
// a permanent environment property, don't retry per file.
_instantUploadUsable = false;
}
settle(data.ok && data.hash ? data.hash : null);
};
worker.onerror = () => {
// Worker script failed to load/parse — permanent.
_instantUploadUsable = false;
settle(null);
};
worker.postMessage({ file });
});
}
/**
* Ask the server whether the caller already owns content with this hash.
* @param {string} hash
* @returns {Promise<boolean>}
*/
async function callerOwnsHash(hash) {
try {
const response = await fetch(`/api/dedup/check/${hash}`, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }
});
if (!response.ok) return false;
const body = /** @type {import('../../core/types.js').HashCheckAnswer} */ (await response.json());
return body.exists === true;
} catch (_) {
return false;
}
}
/**
* Try to register `file` as a zero-byte instant upload.
*
* Returns `null` whenever the byte upload should proceed (file too
* small, environment unusable, hash miss, lost race, transient errors).
* Returns an upload-result object compatible with the uploaders'
* `UploadAnswer` shape when the attempt is conclusive — success, quota
* exceeded, or name conflict (a byte upload would fail identically).
*
* @param {File} file
* @param {string | null | undefined} folderId
* @returns {Promise<{ ok: boolean, data?: any, errorMsg?: string, isQuotaError?: boolean } | null>}
*/
export async function tryInstantUpload(file, folderId) {
if (!folderId || file.size < INSTANT_UPLOAD_MIN_SIZE || _instantUploadUsable === false || typeof Worker === 'undefined') {
return null;
}
const hash = await hashFileInWorker(file);
if (!hash) return null;
if (!(await callerOwnsHash(hash))) return null;
try {
const response = await fetch('/api/files/by-hash', {
method: 'POST',
headers: {
...getCsrfHeaders(),
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
},
body: JSON.stringify(
/** @type {import('../../core/types.js').CreateFileByHash} */ ({
name: file.name,
folder_id: folderId,
hash
})
)
});
if (response.status === 201) {
return { ok: true, data: await response.json() };
}
/** @type {string} */
let errorMsg = `Instant upload failed (HTTP ${response.status})`;
try {
const body = await response.json();
errorMsg = body.message || body.error || errorMsg;
} catch (_) {}
if (response.status === 507) {
return { ok: false, isQuotaError: true, errorMsg };
}
if (response.status === 409) {
// Duplicate name in the folder — a byte upload would hit the
// exact same conflict; surface it without transferring.
return { ok: false, errorMsg };
}
// 404 (ownership race with a delete+GC), 4xx/5xx: fall back to the
// byte upload — the server dedups it on write anyway.
return null;
} catch (_) {
return null;
}
}