feat: delta/instant upload + frontend UI/UX polish
Bundles the backend+frontend delta-upload (content-dedup) feature with a batch of frontend fixes from this session. Upload / dedup: - Client-hashed delta & instant upload (deltaUpload, hashWasm vendor shim) - Backend dedup batch endpoint (dedup_service, dedup_handler, routes) - session store owned-hash helpers; unit tests + upload-strategy bench Frontend UI/UX: - Colour file-type icons in grid/list (per-type tinted tiles + glyph hue) - Robust thumbnail fallback; PDFs now show their type icon (backend generates no PDF thumbnails) instead of a blank tile - Fix PDF preview: load via a same-origin blob: iframe — the API URL is blocked by the global X-Frame-Options: DENY in the browser's framed PDF viewer, matching the existing CSP `frame-src blob:` design - Groups: localized virtual-group description (no DB schema-note leak), add nav.groups to the 15 missing locales, fix primary-button contrast - Repoint --color-text-light → --color-on-accent (was faint grey on accent) - Nudge the admin role badge off the user-menu header divider Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them.
|
||||
const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({
|
||||
blake3Mock: vi.fn(),
|
||||
byHashMock: vi.fn(),
|
||||
batchMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/vendor/hashWasm', () => ({ blake3HexOfFile: blake3Mock }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({
|
||||
createFileByHash: byHashMock,
|
||||
dedupCheckBatch: batchMock
|
||||
}));
|
||||
|
||||
import { DELTA_UPLOAD_MIN_SIZE, instantUploadOwned, resolveOwnedHashes } from './deltaUpload';
|
||||
|
||||
const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File;
|
||||
const hashOf = (name: string) => name.padEnd(64, '0');
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('resolveOwnedHashes (batch check)', () => {
|
||||
beforeEach(() => {
|
||||
blake3Mock.mockReset();
|
||||
batchMock.mockReset();
|
||||
blake3Mock.mockImplementation((f: File) => Promise.resolve(hashOf(f.name)));
|
||||
});
|
||||
|
||||
it('hits nothing when no files are in-band (empty / >= delta threshold)', async () => {
|
||||
const owned = await resolveOwnedHashes([
|
||||
fakeFile(0, 'empty'),
|
||||
fakeFile(DELTA_UPLOAD_MIN_SIZE, 'big')
|
||||
]);
|
||||
expect(owned.size).toBe(0);
|
||||
expect(blake3Mock).not.toHaveBeenCalled();
|
||||
expect(batchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hashes in-band files and maps only the server-owned subset in ONE batch call', async () => {
|
||||
const a = fakeFile(2 * MB, 'a');
|
||||
const b = fakeFile(3 * MB, 'b');
|
||||
batchMock.mockResolvedValue(new Set([hashOf('a')])); // server owns only "a"
|
||||
const owned = await resolveOwnedHashes([a, b]);
|
||||
expect(batchMock).toHaveBeenCalledTimes(1);
|
||||
expect(batchMock).toHaveBeenCalledWith([hashOf('a'), hashOf('b')]);
|
||||
expect(owned.get(a)).toBe(hashOf('a'));
|
||||
expect(owned.has(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to an empty map when client-side hashing fails', async () => {
|
||||
blake3Mock.mockRejectedValue(new Error('wasm down'));
|
||||
const owned = await resolveOwnedHashes([fakeFile(2 * MB, 'a')]);
|
||||
expect(owned.size).toBe(0);
|
||||
expect(batchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to an empty map when the batch request fails', async () => {
|
||||
batchMock.mockRejectedValue(new Error('network'));
|
||||
const owned = await resolveOwnedHashes([fakeFile(2 * MB, 'a')]);
|
||||
expect(owned.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instantUploadOwned (zero-byte create)', () => {
|
||||
beforeEach(() => byHashMock.mockReset());
|
||||
|
||||
it('reports zero-byte success on 201', async () => {
|
||||
byHashMock.mockResolvedValue({ ok: true, status: 201, data: { id: 'f1' } });
|
||||
const r = await instantUploadOwned('folder', fakeFile(2 * MB, 'a'), hashOf('a'));
|
||||
expect(r).toEqual({ ok: true, data: { id: 'f1' }, savedBytes: 2 * MB });
|
||||
expect(byHashMock).toHaveBeenCalledWith('folder', 'a', hashOf('a'));
|
||||
});
|
||||
|
||||
it('falls back (null) when the blob vanished (404)', async () => {
|
||||
byHashMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
expect(await instantUploadOwned('folder', fakeFile(2 * MB), hashOf('a'))).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a quota error on 507', async () => {
|
||||
byHashMock.mockResolvedValue({ ok: false, status: 507 });
|
||||
expect(await instantUploadOwned('folder', fakeFile(2 * MB), hashOf('a'))).toEqual({
|
||||
ok: false,
|
||||
isQuotaError: true,
|
||||
errorMsg: 'Storage quota exceeded'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@
|
||||
* byte upload — delta is an optimization, never a gate.
|
||||
*/
|
||||
import { getCsrfToken } from '$lib/api/csrf';
|
||||
import { createFileByHash, dedupCheckBatch } from '$lib/api/endpoints/files';
|
||||
import { blake3HexOfFile } from '$lib/vendor/hashWasm';
|
||||
|
||||
/** Files smaller than this skip delta: the round-trips cost more than the bytes. */
|
||||
export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
|
||||
@@ -128,3 +130,58 @@ export function tryDeltaUpload(
|
||||
worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file from a blob the caller already owns (`POST /api/files/by-hash`)
|
||||
* — zero content bytes cross the wire. `hash` must come from a prior batch
|
||||
* ownership check ([`resolveOwnedHashes`]). Resolves an answer with
|
||||
* `savedBytes = file.size` on success, surfaces a 507 quota error, or resolves
|
||||
* `null` to fall back to a normal upload (e.g. the blob was GC'd between the
|
||||
* check and this create — rare).
|
||||
*/
|
||||
export async function instantUploadOwned(
|
||||
folderId: string,
|
||||
file: File,
|
||||
hash: string
|
||||
): Promise<DeltaUploadAnswer | null> {
|
||||
const res = await createFileByHash(folderId, file.name, hash);
|
||||
if (res.ok) return { ok: true, data: res.data, savedBytes: file.size };
|
||||
if (res.status === 507) {
|
||||
return { ok: false, isQuotaError: true, errorMsg: 'Storage quota exceeded' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which of `files` the server already owns, with a SINGLE batch round
|
||||
* trip (the Dropbox-style "have you got these?" probe). Every file below the
|
||||
* delta threshold is BLAKE3-hashed locally, the whole hash set is sent to
|
||||
* `/api/dedup/check-batch`, and the owned subset is mapped back to `file → hash`
|
||||
* so callers can instant-upload those (zero bytes) and upload the rest normally.
|
||||
*
|
||||
* Excludes empty files and files `>= DELTA_UPLOAD_MIN_SIZE` (the delta protocol
|
||||
* dedups those itself). Resolves an empty map on any failure — hashing
|
||||
* unavailable, request error — so uploads always proceed.
|
||||
*/
|
||||
export async function resolveOwnedHashes(files: File[]): Promise<Map<File, string>> {
|
||||
const inBand = files.filter((f) => f.size > 0 && f.size < DELTA_UPLOAD_MIN_SIZE);
|
||||
if (inBand.length === 0) return new Map();
|
||||
|
||||
const hashByFile = new Map<File, string>();
|
||||
try {
|
||||
for (const f of inBand) hashByFile.set(f, await blake3HexOfFile(f));
|
||||
} catch {
|
||||
return new Map(); // WASM/hashing unavailable → skip instant uploads
|
||||
}
|
||||
|
||||
let owned: Set<string>;
|
||||
try {
|
||||
owned = await dedupCheckBatch([...new Set(hashByFile.values())]);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = new Map<File, string>();
|
||||
for (const [f, h] of hashByFile) if (owned.has(h)) result.set(f, h);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,46 @@ import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
/**
|
||||
* Instant upload: materialise a file from a blob the caller **already owns**,
|
||||
* by its whole-file BLAKE3 — zero content bytes cross the wire. Returns the HTTP
|
||||
* status so the caller can fall back to a plain upload on 404 (hash not owned).
|
||||
* Scoped to the caller's own content server-side (no cross-user probing).
|
||||
*/
|
||||
export async function createFileByHash(
|
||||
folderId: string,
|
||||
name: string,
|
||||
hash: string
|
||||
): Promise<{ ok: boolean; status: number; data?: unknown }> {
|
||||
const res = await apiFetch('/api/files/by-hash', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ name, folder_id: folderId, hash })
|
||||
});
|
||||
const data = res.ok ? await res.json().catch(() => undefined) : undefined;
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch dedup check: given candidate whole-file BLAKE3 hashes, return the set
|
||||
* the caller **already owns** — in a single round trip. Drives instant uploads:
|
||||
* a file whose hash is in the set can be created with zero content bytes.
|
||||
* Resolves an empty set on any failure, so the caller just uploads everything.
|
||||
*/
|
||||
export async function dedupCheckBatch(hashes: string[]): Promise<Set<string>> {
|
||||
if (hashes.length === 0) return new Set();
|
||||
const res = await apiFetch('/api/dedup/check-batch', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ hashes })
|
||||
});
|
||||
if (!res.ok) return new Set();
|
||||
const data = (await res.json().catch(() => null)) as { owned?: string[] } | null;
|
||||
return new Set(data?.owned ?? []);
|
||||
}
|
||||
|
||||
export async function uploadFile(folderId: string | null, file: File): Promise<void> {
|
||||
const form = new FormData();
|
||||
if (folderId) form.append('folder_id', folderId);
|
||||
|
||||
@@ -22,6 +22,17 @@ const VIRTUAL_NAME_KEYS: Record<string, string> = {
|
||||
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_name'
|
||||
};
|
||||
|
||||
/**
|
||||
* Map of well-known virtual-group UUIDs → i18n key for a human-readable
|
||||
* description. Virtual groups are server-seeded and their `description` column
|
||||
* holds developer/schema notes (e.g. the Internal group's "…no rows in
|
||||
* subject_group_members."), which must never reach end users — so virtual
|
||||
* groups display a localized blurb instead of their raw `description`.
|
||||
*/
|
||||
const VIRTUAL_DESC_KEYS: Record<string, string> = {
|
||||
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_explanation'
|
||||
};
|
||||
|
||||
export interface GroupItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -87,6 +98,20 @@ export function groupDisplayName(group: GroupItem): string {
|
||||
return group.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable description for a group row. Virtual groups render a localized
|
||||
* blurb (via the well-known UUID mapping) so their internal DB schema notes
|
||||
* never leak to the UI; a virtual group without a mapped key shows nothing.
|
||||
* User-defined groups display their raw `description` verbatim.
|
||||
*/
|
||||
export function groupDescription(group: GroupItem): string | null {
|
||||
if (group.is_virtual) {
|
||||
const key = VIRTUAL_DESC_KEYS[group.id];
|
||||
return key ? t(key, group.name) : null;
|
||||
}
|
||||
return group.description ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the icon registry name for a group avatar. Virtual (system-wide)
|
||||
* groups use `people-roof`; user-defined groups use `user-group`. Ported from
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Benchmark: upload-dedup strategies compared.
|
||||
*
|
||||
* BASELINE — no instant upload: every file's bytes are sent.
|
||||
* PER-FILE — instant upload probed one file at a time (a by-hash request per
|
||||
* file; a miss costs an extra round trip before the plain upload).
|
||||
* BATCH — Dropbox-style: hash every file, ONE `/api/dedup/check-batch`
|
||||
* request, then instant-upload the owned ones and send the rest.
|
||||
*
|
||||
* It's an analytic model (round trips × RTT + bytes / bandwidth + hashing
|
||||
* time), not a live transfer — the point is to compare the strategies' network
|
||||
* cost. Run it to see the table:
|
||||
* npm run test:unit -- uploadStrategies
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface Cost {
|
||||
roundTrips: number;
|
||||
mbSent: number;
|
||||
mbHashed: number;
|
||||
seconds: number;
|
||||
}
|
||||
interface Scenario {
|
||||
name: string;
|
||||
files: number;
|
||||
sizeMB: number;
|
||||
ownedFrac: number;
|
||||
rttMs: number;
|
||||
mbps: number;
|
||||
}
|
||||
|
||||
/** BLAKE3 + file read throughput on a typical client (MB/s). */
|
||||
const HASH_MBPS = 1500;
|
||||
|
||||
function cost(roundTrips: number, mbSent: number, mbHashed: number, s: Scenario): Cost {
|
||||
const linkMBps = s.mbps / 8;
|
||||
const seconds = roundTrips * (s.rttMs / 1000) + mbSent / linkMBps + mbHashed / HASH_MBPS;
|
||||
return { roundTrips, mbSent, mbHashed, seconds };
|
||||
}
|
||||
|
||||
function baseline(s: Scenario): Cost {
|
||||
return cost(s.files, s.files * s.sizeMB, 0, s);
|
||||
}
|
||||
function perFile(s: Scenario): Cost {
|
||||
const owned = Math.round(s.files * s.ownedFrac);
|
||||
const miss = s.files - owned;
|
||||
// owned → 1 by-hash create; miss → by-hash 404 + plain upload. Every file hashed.
|
||||
return cost(owned + miss * 2, miss * s.sizeMB, s.files * s.sizeMB, s);
|
||||
}
|
||||
function batch(s: Scenario): Cost {
|
||||
const owned = Math.round(s.files * s.ownedFrac);
|
||||
const miss = s.files - owned;
|
||||
// 1 batch check + owned creates + miss uploads. Every file hashed.
|
||||
return cost(1 + owned + miss, miss * s.sizeMB, s.files * s.sizeMB, s);
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{
|
||||
name: '200×4MB · 50% re-upload · home (40ms/50Mbps)',
|
||||
files: 200,
|
||||
sizeMB: 4,
|
||||
ownedFrac: 0.5,
|
||||
rttMs: 40,
|
||||
mbps: 50
|
||||
},
|
||||
{
|
||||
name: '200×4MB · ALL new · home (40ms/50Mbps)',
|
||||
files: 200,
|
||||
sizeMB: 4,
|
||||
ownedFrac: 0,
|
||||
rttMs: 40,
|
||||
mbps: 50
|
||||
},
|
||||
{
|
||||
name: '200×4MB · ALL owned (re-sync) · home',
|
||||
files: 200,
|
||||
sizeMB: 4,
|
||||
ownedFrac: 1,
|
||||
rttMs: 40,
|
||||
mbps: 50
|
||||
},
|
||||
{
|
||||
name: '1000×0.5MB · 30% owned · WAN (80ms/100Mbps)',
|
||||
files: 1000,
|
||||
sizeMB: 0.5,
|
||||
ownedFrac: 0.3,
|
||||
rttMs: 80,
|
||||
mbps: 100
|
||||
}
|
||||
];
|
||||
|
||||
describe('upload-dedup strategies', () => {
|
||||
it('batch never sends more bytes than baseline and matches per-file dedup', () => {
|
||||
const rows: string[] = [];
|
||||
rows.push('');
|
||||
rows.push('╔══ Upload-dedup strategies — analytic cost model ══════════════════════════');
|
||||
for (const s of SCENARIOS) {
|
||||
const b = baseline(s);
|
||||
const p = perFile(s);
|
||||
const z = batch(s);
|
||||
const line = (tag: string, c: Cost) =>
|
||||
`║ ${tag.padEnd(9)} │ RT ${String(c.roundTrips).padStart(4)} │ sent ${c.mbSent
|
||||
.toFixed(0)
|
||||
.padStart(4)} MB │ ~${c.seconds.toFixed(1).padStart(6)} s`;
|
||||
rows.push(`╟─ ${s.name}`);
|
||||
rows.push(line('baseline', b));
|
||||
rows.push(line('per-file', p));
|
||||
rows.push(line('BATCH', z));
|
||||
const vsBase = (1 - z.seconds / b.seconds) * 100;
|
||||
const rtVsPerFile = p.roundTrips - z.roundTrips;
|
||||
rows.push(
|
||||
`║ → BATCH: ${vsBase.toFixed(0)}% faster than baseline · ${rtVsPerFile} fewer round trips than per-file`
|
||||
);
|
||||
|
||||
// Invariants the strategies must satisfy:
|
||||
expect(z.mbSent).toBe(p.mbSent); // batch and per-file dedup identically
|
||||
expect(z.mbSent).toBeLessThanOrEqual(b.mbSent); // never worse than baseline on bytes
|
||||
// When there's anything to dedup, batch is clearly faster than baseline.
|
||||
// (With NOTHING owned, batch pays hashing + one check for no payoff — a
|
||||
// small, honest overhead the table shows.)
|
||||
if (s.ownedFrac > 0) expect(z.seconds).toBeLessThan(b.seconds);
|
||||
// Batch collapses the N per-file probes into one check: for any miss it
|
||||
// strictly wins on round trips (and is at most +1 in the all-owned case).
|
||||
if (s.ownedFrac < 1) expect(z.roundTrips).toBeLessThan(p.roundTrips);
|
||||
}
|
||||
rows.push('╚═══════════════════════════════════════════════════════════════════════════');
|
||||
console.log(rows.join('\n'));
|
||||
});
|
||||
});
|
||||
@@ -40,9 +40,14 @@
|
||||
let canEdit = $state(false);
|
||||
/** Image zoom factor (1 = fit). */
|
||||
let zoom = $state(1);
|
||||
/** PDF embed fallback engaged when the <object> stays blank ~2s. */
|
||||
let pdfFallback = $state(false);
|
||||
let pdfObjectEl = $state<HTMLObjectElement | null>(null);
|
||||
/** Object URL for the fetched PDF blob. The PDF is rendered from a same-origin
|
||||
* blob: in an <iframe> (allowed by CSP `frame-src blob:`) rather than from its
|
||||
* API URL directly — that response carries the global `X-Frame-Options: DENY`
|
||||
* + `frame-ancestors 'none'`, which the browser's framed PDF viewer honours,
|
||||
* so an embedded API URL renders as a broken-document icon. A blob has no
|
||||
* response headers, so it sidesteps the framing block. */
|
||||
let pdfUrl = $state<string | null>(null);
|
||||
let pdfError = $state(false);
|
||||
|
||||
function isImage(f: FileItem): boolean {
|
||||
const m = (f.mime_type ?? '').toLowerCase();
|
||||
@@ -72,7 +77,7 @@
|
||||
open = false;
|
||||
textContent = '';
|
||||
zoom = 1;
|
||||
pdfFallback = false;
|
||||
pdfError = false;
|
||||
onrefresh?.();
|
||||
}
|
||||
|
||||
@@ -94,7 +99,6 @@
|
||||
const f = file;
|
||||
canEdit = false;
|
||||
zoom = 1;
|
||||
pdfFallback = false;
|
||||
const k = kindOf(f);
|
||||
|
||||
// Office docs (WOPI-editable, non-image) open straight in the editor
|
||||
@@ -119,21 +123,25 @@
|
||||
.finally(() => (textLoading = false));
|
||||
}
|
||||
|
||||
// PDF blank-render guard: if the <object> shows nothing after ~2s,
|
||||
// fall back to an <embed> (some browsers refuse <object> for PDFs).
|
||||
// Fetch the PDF as a same-origin blob and view it via an object URL (see
|
||||
// `pdfUrl`). The cleanup revokes the URL when the file changes or the
|
||||
// viewer closes; a stale response (newer file opened mid-fetch) is dropped.
|
||||
if (k === 'pdf') {
|
||||
const timer = setTimeout(() => {
|
||||
const el = pdfObjectEl;
|
||||
let blank = false;
|
||||
try {
|
||||
const doc = el?.contentDocument;
|
||||
blank = !doc || doc.body?.innerHTML === '';
|
||||
} catch {
|
||||
blank = false; // cross-origin: assume it rendered
|
||||
}
|
||||
if (blank) pdfFallback = true;
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
pdfError = false;
|
||||
pdfUrl = null;
|
||||
let objectUrl: string | null = null;
|
||||
apiFetch(fileInlineUrl(f.id), { credentials: 'same-origin' })
|
||||
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((blob) => {
|
||||
if (file !== f || !open) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
pdfUrl = objectUrl;
|
||||
})
|
||||
.catch(() => (pdfError = true));
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
pdfUrl = null;
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -220,18 +228,15 @@
|
||||
{:else if kind === 'audio'}
|
||||
<audio class="fv__audio" src={fileInlineUrl(file.id)} controls></audio>
|
||||
{:else if kind === 'pdf'}
|
||||
{#if pdfFallback}
|
||||
<embed class="fv__pdf" src={fileInlineUrl(file.id)} type="application/pdf" />
|
||||
{:else}
|
||||
<object
|
||||
bind:this={pdfObjectEl}
|
||||
class="fv__pdf"
|
||||
data={fileInlineUrl(file.id)}
|
||||
type="application/pdf"
|
||||
title={file.name}
|
||||
>
|
||||
{#if pdfError}
|
||||
<div class="fv__status fv__status--center">
|
||||
<Icon name="file" class="fv__big-icon" />
|
||||
<p>{t('files.preview_failed', 'Could not load preview.')}</p>
|
||||
</object>
|
||||
</div>
|
||||
{:else if pdfUrl}
|
||||
<iframe class="fv__pdf" src={pdfUrl} title={file.name}></iframe>
|
||||
{:else}
|
||||
<p class="fv__status">{t('common.loading', 'Loading…')}</p>
|
||||
{/if}
|
||||
{:else if kind === 'text'}
|
||||
{#if textLoading}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
import { gridColumns } from '$lib/utils/grid';
|
||||
|
||||
interface Props {
|
||||
@@ -301,6 +301,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet row(entry: ResourceEntry)}
|
||||
{@const iconName = entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="file-item"
|
||||
@@ -323,8 +324,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="name-cell">
|
||||
<span class="file-icon">
|
||||
<Icon name={entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)} />
|
||||
<span class="file-icon {fileIconKindClass(iconName)}">
|
||||
<Icon name={iconName} />
|
||||
</span>
|
||||
<span class="name-cell__text">{entry.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '$lib/api/types';
|
||||
|
||||
// `vi.mock` is hoisted above imports, so the spy it references must be created
|
||||
// with `vi.hoisted` (a plain top-level const isn't initialised yet when the
|
||||
// factory runs).
|
||||
const { fetchMeMock } = vi.hoisted(() => ({ fetchMeMock: vi.fn() }));
|
||||
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({
|
||||
fetchMe: () => fetchMeMock(),
|
||||
tryRefresh: vi.fn(async () => false)
|
||||
}));
|
||||
|
||||
import { session } from './session.svelte';
|
||||
|
||||
const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User;
|
||||
|
||||
describe('session.refresh', () => {
|
||||
beforeEach(() => {
|
||||
fetchMeMock.mockReset();
|
||||
session.reset();
|
||||
});
|
||||
|
||||
it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => {
|
||||
fetchMeMock.mockResolvedValue(userWithUsage(2048));
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
|
||||
it('leaves the current user intact when the probe returns null', async () => {
|
||||
fetchMeMock.mockResolvedValue(userWithUsage(2048));
|
||||
await session.refresh();
|
||||
fetchMeMock.mockResolvedValue(null);
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
|
||||
it('leaves the current user intact when the probe throws', async () => {
|
||||
fetchMeMock.mockResolvedValue(userWithUsage(2048));
|
||||
await session.refresh();
|
||||
fetchMeMock.mockRejectedValue(new Error('network'));
|
||||
await session.refresh();
|
||||
expect(session.user?.storage_used_bytes).toBe(2048);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,22 @@ class SessionStore {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the authenticated user from the server, bypassing the one-shot
|
||||
* `load()` cache. Call after operations that change server-side user state —
|
||||
* chiefly storage usage after uploads / deletes — so the UI reflects the new
|
||||
* `storage_used_bytes` instead of the value cached at login. A transient
|
||||
* failure leaves the current user untouched (never logs the UI out).
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
if (me) this.user = me;
|
||||
} catch {
|
||||
/* keep the existing user on a transient /api/auth/me failure */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's default personal drive's root folder — the landing
|
||||
* point for `/files` and the `/` redirect. Externals (grant-only) have no
|
||||
|
||||
@@ -236,7 +236,11 @@
|
||||
--color-text-gray: var(--color-text-muted);
|
||||
--color-text-medium: var(--color-text-muted);
|
||||
--color-text-faint2: var(--color-text-faint);
|
||||
--color-text-light: var(--color-text-faint);
|
||||
/* "Light" = light-coloured (near-white) text for accent/dark fills, NOT a
|
||||
* faint tier. Every consumer is a primary button (background: --color-primary),
|
||||
* so this must resolve to the on-accent foreground, not muted grey — which
|
||||
* rendered muddy and failed contrast on the orange accent. */
|
||||
--color-text-light: var(--color-on-accent);
|
||||
--color-text-placeholder: var(--color-text-faint);
|
||||
|
||||
/* Accent (orange) — mostly mode-agnostic. */
|
||||
@@ -644,6 +648,24 @@
|
||||
/* Status badge — gray/disabled */
|
||||
--color-badge-gray: #d1d5db;
|
||||
|
||||
/* File-type glyph colours — one vivid hue per broad file family, used by the
|
||||
* .file-icon--* buckets (resourceList.css) so the grid/list type icons read
|
||||
* as colourful tiles instead of flat monochrome. Tile tints are derived from
|
||||
* these with color-mix(), so only the foreground hue lives here. Dark-mode
|
||||
* shades are lightened for contrast against the dark tile fill. */
|
||||
--file-kind-folder: light-dark(#3b82f6, #60a5fa);
|
||||
--file-kind-pdf: light-dark(#ef4444, #f87171);
|
||||
--file-kind-doc: light-dark(#2563eb, #60a5fa);
|
||||
--file-kind-sheet: light-dark(#16a34a, #4ade80);
|
||||
--file-kind-slides: light-dark(#ea580c, #fb923c);
|
||||
--file-kind-archive: light-dark(#d97706, #fbbf24);
|
||||
--file-kind-code: light-dark(#7c3aed, #a78bfa);
|
||||
--file-kind-image: light-dark(#0891b2, #22d3ee);
|
||||
--file-kind-video: light-dark(#c026d3, #e879f9);
|
||||
--file-kind-audio: light-dark(#db2777, #f472b6);
|
||||
--file-kind-text: light-dark(#475569, #94a3b8);
|
||||
--file-kind-generic: light-dark(#64748b, #94a3b8);
|
||||
|
||||
/* (Removed: the legacy dark-mode badge tokens that lived here had zero
|
||||
* consumers — the light-dark() badge tokens above are the single source.) */
|
||||
|
||||
|
||||
@@ -43,6 +43,69 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── File-type colours ───────────────────────────────────────
|
||||
Each bucket sets a local --fk (the foreground hue, from the
|
||||
--file-kind-* tokens). The glyph takes --fk directly and the tile
|
||||
fill/ring derive soft tints from it with color-mix(), so the icons read
|
||||
as colourful tiles instead of flat monochrome. Adding a bucket is a
|
||||
single custom-property line; the consuming rules never change. */
|
||||
.file-icon {
|
||||
--fk: var(--file-kind-generic);
|
||||
}
|
||||
|
||||
.file-icon--folder {
|
||||
--fk: var(--file-kind-folder);
|
||||
}
|
||||
|
||||
.file-icon--pdf {
|
||||
--fk: var(--file-kind-pdf);
|
||||
}
|
||||
|
||||
.file-icon--doc {
|
||||
--fk: var(--file-kind-doc);
|
||||
}
|
||||
|
||||
.file-icon--sheet {
|
||||
--fk: var(--file-kind-sheet);
|
||||
}
|
||||
|
||||
.file-icon--slides {
|
||||
--fk: var(--file-kind-slides);
|
||||
}
|
||||
|
||||
.file-icon--archive {
|
||||
--fk: var(--file-kind-archive);
|
||||
}
|
||||
|
||||
.file-icon--code {
|
||||
--fk: var(--file-kind-code);
|
||||
}
|
||||
|
||||
.file-icon--image {
|
||||
--fk: var(--file-kind-image);
|
||||
}
|
||||
|
||||
.file-icon--video {
|
||||
--fk: var(--file-kind-video);
|
||||
}
|
||||
|
||||
.file-icon--audio {
|
||||
--fk: var(--file-kind-audio);
|
||||
}
|
||||
|
||||
.file-icon--text {
|
||||
--fk: var(--file-kind-text);
|
||||
}
|
||||
|
||||
.file-icon--generic {
|
||||
--fk: var(--file-kind-generic);
|
||||
}
|
||||
|
||||
.file-icon > i,
|
||||
.file-icon > svg {
|
||||
color: var(--fk);
|
||||
}
|
||||
|
||||
/* ── Item states ─────────────────────────────────────────── */
|
||||
|
||||
.file-item {
|
||||
@@ -355,9 +418,10 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-lg);
|
||||
/* Hairline inner ring — consistent with the grid thumbnails so light
|
||||
previews don't bleed into the row. */
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
/* Soft type-tinted tile + matching hairline ring so the row glyph reads as
|
||||
a colourful chip (and light previews don't bleed into the row). */
|
||||
background: color-mix(in srgb, var(--fk) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fk) 22%, var(--color-border));
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
@@ -859,9 +923,11 @@
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-input);
|
||||
/* Hairline inner ring so light thumbnails don't bleed into the card. */
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
/* Soft type-tinted fill + matching hairline ring (a thumbnail, when
|
||||
present, covers both edge-to-edge). Gives non-preview files a modern
|
||||
colourful tile keyed to their type instead of a flat grey panel. */
|
||||
background: color-mix(in srgb, var(--fk) 9%, var(--color-bg-input));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fk) 22%, var(--color-border));
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@
|
||||
}
|
||||
|
||||
.user-menu-role-badge {
|
||||
padding: 0 var(--space-5) var(--space-1);
|
||||
padding: var(--space-2) var(--space-5) var(--space-1);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
|
||||
@@ -12,6 +12,50 @@ export function iconNameFromClass(iconClass: string | undefined | null): string
|
||||
return token ? token.slice(3) : 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse colour bucket for a resolved icon name (see {@link iconNameFromClass}).
|
||||
* One hue per broad file family so the grid/list glyphs render in type-specific
|
||||
* colours instead of a flat monochrome. Each bucket is backed by a
|
||||
* `--file-kind-*` token (variables.css) and consumed via the `.file-icon--*`
|
||||
* modifier classes (resourceList.css).
|
||||
*/
|
||||
export function fileIconKind(iconName: string): string {
|
||||
switch (iconName) {
|
||||
case 'folder':
|
||||
case 'folder-open':
|
||||
return 'folder';
|
||||
case 'file-pdf':
|
||||
return 'pdf';
|
||||
case 'file-word':
|
||||
return 'doc';
|
||||
case 'file-excel':
|
||||
return 'sheet';
|
||||
case 'file-powerpoint':
|
||||
return 'slides';
|
||||
case 'file-archive':
|
||||
case 'file-zipper':
|
||||
return 'archive';
|
||||
case 'file-code':
|
||||
return 'code';
|
||||
case 'file-image':
|
||||
return 'image';
|
||||
case 'file-video':
|
||||
return 'video';
|
||||
case 'file-audio':
|
||||
return 'audio';
|
||||
case 'file-alt':
|
||||
case 'file-lines':
|
||||
return 'text';
|
||||
default:
|
||||
return 'generic';
|
||||
}
|
||||
}
|
||||
|
||||
/** `.file-icon` colour-bucket modifier class for a resolved icon name. */
|
||||
export function fileIconKindClass(iconName: string): string {
|
||||
return `file-icon--${fileIconKind(iconName)}`;
|
||||
}
|
||||
|
||||
/** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */
|
||||
export function formatDate(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Lazy loader + minimal typing for the vendored OxiCloud hash WASM
|
||||
* (`/vendors/hash-wasm/oxicloud_hash_wasm.js`) — the same BLAKE3 crate the
|
||||
* server and the delta worker use. Loaded on the main thread to compute a
|
||||
* small file's whole-file BLAKE3 for instant ("by-hash") upload; large files
|
||||
* hash off-thread inside the delta worker instead.
|
||||
*/
|
||||
|
||||
interface HashWasmModule {
|
||||
/** wasm-bindgen init; resolves once the `.wasm` is instantiated. */
|
||||
default: () => Promise<unknown>;
|
||||
/** One-shot BLAKE3 of a buffer → 64-char lowercase hex. */
|
||||
blake3Hex: (data: Uint8Array) => string;
|
||||
}
|
||||
|
||||
const WASM_GLUE_URL = '/vendors/hash-wasm/oxicloud_hash_wasm.js';
|
||||
|
||||
let modPromise: Promise<HashWasmModule> | null = null;
|
||||
|
||||
function load(): Promise<HashWasmModule> {
|
||||
if (!modPromise) {
|
||||
modPromise = (async () => {
|
||||
// Runtime URL of a vendored asset (not a project module) — keep Vite
|
||||
// from trying to resolve/bundle it, exactly like the delta worker does.
|
||||
const mod = (await import(/* @vite-ignore */ WASM_GLUE_URL)) as unknown as HashWasmModule;
|
||||
await mod.default();
|
||||
return mod;
|
||||
})().catch((err) => {
|
||||
modPromise = null; // let a later call retry after a transient failure
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return modPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-file BLAKE3 (64-char lowercase hex) of `file`. Matches the server's
|
||||
* `file_hash` (BLAKE3 over the whole content), so it can be handed to
|
||||
* `/api/files/by-hash`. Reads the file fully into memory — intended for small
|
||||
* files only.
|
||||
*/
|
||||
export async function blake3HexOfFile(file: File): Promise<string> {
|
||||
const mod = await load();
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
return mod.blake3Hex(bytes);
|
||||
}
|
||||
@@ -26,11 +26,14 @@
|
||||
fileThumbnailUrl,
|
||||
moveFile,
|
||||
renameFile,
|
||||
uploadFile,
|
||||
uploadFileWithProgress
|
||||
} from '$lib/api/endpoints/files';
|
||||
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
||||
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
||||
import {
|
||||
instantUploadOwned,
|
||||
resolveOwnedHashes,
|
||||
tryDeltaUpload
|
||||
} from '$lib/api/endpoints/deltaUpload';
|
||||
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
||||
@@ -56,7 +59,7 @@
|
||||
typeLabel
|
||||
} from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
import { gridColumns } from '$lib/utils/grid';
|
||||
|
||||
// File preview and the WOPI editor are heavy and only appear on demand, so
|
||||
@@ -286,6 +289,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one file through the best available path, returning the bytes saved
|
||||
* by deduplication (0 when the body was sent in full). Order:
|
||||
* 1. Instant by-hash upload — zero bytes when `ownedHash` is set (the batch
|
||||
* check found the server already has this exact blob).
|
||||
* 2. Delta upload — sub-file CDC dedup for large files (>= 8 MB).
|
||||
* 3. Plain byte upload — fallback when neither applies.
|
||||
* Throws on a hard failure (e.g. quota).
|
||||
*/
|
||||
async function uploadOneFile(
|
||||
folderId: string | null,
|
||||
file: File,
|
||||
report: (frac: number) => void,
|
||||
ownedHash: string | null
|
||||
): Promise<number> {
|
||||
const dedup =
|
||||
(ownedHash && folderId ? await instantUploadOwned(folderId, file, ownedHash) : null) ??
|
||||
(await tryDeltaUpload(file, folderId, (pct) => report(pct / 100)));
|
||||
if (dedup) {
|
||||
if (!dedup.ok) throw new Error(dedup.errorMsg ?? 'upload failed');
|
||||
return dedup.savedBytes ?? 0;
|
||||
}
|
||||
await uploadFileWithProgress(folderId, file, report);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Final bell message for a finished upload, noting deduplicated bytes. */
|
||||
function uploadDoneMessage(savedBytes: number): string {
|
||||
if (savedBytes <= 0) return t('files.uploaded', 'Upload complete');
|
||||
const mb = (savedBytes / (1024 * 1024)).toFixed(1);
|
||||
return t('files.uploaded_saved', { mb }, `Upload complete — ${mb} MB deduplicated`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a batch of files into the current folder, reporting aggregate
|
||||
* progress through a single bell notification with a progress bar.
|
||||
@@ -301,32 +337,23 @@
|
||||
const nid = ui.startProgress(label(0));
|
||||
let savedBytes = 0;
|
||||
try {
|
||||
// One batch round trip: which of these files does the server already
|
||||
// have? Owned ones upload as zero bytes; the rest go delta/plain.
|
||||
const owned = await resolveOwnedHashes(files);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const report = (frac: number) => {
|
||||
const base = i / total;
|
||||
const step = Number.isNaN(frac) ? 0 : frac / total;
|
||||
ui.updateProgress(nid, Math.round((base + step) * 100), label(i));
|
||||
};
|
||||
// Delta upload for large files (dedup); transparently falls back.
|
||||
const delta = await tryDeltaUpload(files[i], currentId, (pct) => report(pct / 100));
|
||||
if (delta) {
|
||||
if (!delta.ok) throw new Error(delta.errorMsg ?? 'upload failed');
|
||||
savedBytes += delta.savedBytes ?? 0;
|
||||
} else {
|
||||
await uploadFileWithProgress(currentId, files[i], report);
|
||||
}
|
||||
savedBytes += await uploadOneFile(currentId, files[i], report, owned.get(files[i]) ?? null);
|
||||
ui.updateProgress(nid, Math.round(((i + 1) / total) * 100), label(i + 1));
|
||||
}
|
||||
const done =
|
||||
savedBytes > 0
|
||||
? t(
|
||||
'files.uploaded_saved',
|
||||
{ mb: (savedBytes / (1024 * 1024)).toFixed(1) },
|
||||
`Upload complete — ${(savedBytes / (1024 * 1024)).toFixed(1)} MB deduplicated`
|
||||
)
|
||||
: t('files.uploaded', 'Upload complete');
|
||||
ui.finishProgress(nid, done, 'success');
|
||||
ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success');
|
||||
await reload();
|
||||
// Storage usage changed server-side — pull the fresh figure so the
|
||||
// "Almacenamiento" bar moves off its login value instead of 0%.
|
||||
void session.refresh();
|
||||
} catch (err) {
|
||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||
} finally {
|
||||
@@ -430,6 +457,7 @@
|
||||
if (kind === 'file') await deleteFile(id);
|
||||
else await deleteFolder(id);
|
||||
await reload();
|
||||
void session.refresh();
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
@@ -480,16 +508,16 @@
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the server can render a thumbnail preview for this file. Images and
|
||||
* videos always have one; PDFs (and other thumbnail-capable docs) do too, so
|
||||
* surface those rather than a generic icon. A failed <img> load falls back to
|
||||
* the icon via onerror, so being permissive here is safe.
|
||||
* Whether the server can render a thumbnail preview for this file. Only images
|
||||
* and videos have server-side thumbnails (see `ThumbnailService::is_supported_image`
|
||||
* plus client-uploaded video frames); the backend does NOT rasterise PDFs or
|
||||
* documents, so claiming it could left their tiles blank (the doomed <img>
|
||||
* 404s and `onerror` hides it). Non-thumbnail files fall back to their colour
|
||||
* type icon, which renders underneath the <img> regardless.
|
||||
*/
|
||||
function canThumbnail(file: FileItem): boolean {
|
||||
const m = file.mime_type ?? '';
|
||||
if (m.startsWith('image/') || m.startsWith('video/')) return true;
|
||||
if (m === 'application/pdf') return true;
|
||||
return /\.pdf$/i.test(file.name);
|
||||
return m.startsWith('image/') || m.startsWith('video/');
|
||||
}
|
||||
|
||||
// ── Multi-select + batch ────────────────────────────────────────────────
|
||||
@@ -690,6 +718,7 @@
|
||||
}
|
||||
clearSelection();
|
||||
await reload();
|
||||
void session.refresh();
|
||||
}
|
||||
|
||||
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
||||
@@ -955,6 +984,13 @@
|
||||
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||
if (entries.length === 0) return;
|
||||
uploading = true;
|
||||
const total = entries.length;
|
||||
const label = (done: number) =>
|
||||
t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
||||
// Same bell progress notification as uploadBatch, so folder uploads show
|
||||
// live progress + a final result instead of staying silent until the end.
|
||||
const nid = ui.startProgress(label(0));
|
||||
let savedBytes = 0;
|
||||
try {
|
||||
// Map each relative directory path to its created folder id; '' = current.
|
||||
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||
@@ -969,16 +1005,31 @@
|
||||
return created.id;
|
||||
}
|
||||
|
||||
for (const { file, relativePath } of entries) {
|
||||
// One batch round trip for the whole tree: which files does the server
|
||||
// already have? Owned ones upload as zero bytes.
|
||||
const owned = await resolveOwnedHashes(entries.map((e) => e.file));
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { file, relativePath } = entries[i];
|
||||
const segs = relativePath.split('/');
|
||||
segs.pop(); // drop the filename, keep the directory trail
|
||||
const dirId = await ensureDir(segs.join('/'));
|
||||
await uploadFile(dirId, file);
|
||||
savedBytes += await uploadOneFile(
|
||||
dirId,
|
||||
file,
|
||||
(frac) => {
|
||||
const base = i / total;
|
||||
const step = Number.isNaN(frac) ? 0 : frac / total;
|
||||
ui.updateProgress(nid, Math.round((base + step) * 100), label(i));
|
||||
},
|
||||
owned.get(file) ?? null
|
||||
);
|
||||
ui.updateProgress(nid, Math.round(((i + 1) / total) * 100), label(i + 1));
|
||||
}
|
||||
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
||||
ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success');
|
||||
await reload();
|
||||
void session.refresh();
|
||||
} catch (err) {
|
||||
errorToast(err);
|
||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
@@ -1458,7 +1509,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="name-cell">
|
||||
<div class="file-icon"><Icon name="folder" /></div>
|
||||
<div class="file-icon file-icon--folder"><Icon name="folder" /></div>
|
||||
<span title={folder.name}>{folder.name}</span>
|
||||
{#if favoriteIds.has(folder.id)}<div
|
||||
class="item-badge item-badge--fav"
|
||||
@@ -1538,6 +1589,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet fileRow(file: FileItem)}
|
||||
{@const iconName = iconNameFromClass(file.icon_class)}
|
||||
<div
|
||||
class="file-item"
|
||||
class:selected={selected.has(file.id)}
|
||||
@@ -1564,7 +1616,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="name-cell">
|
||||
<div class="file-icon">
|
||||
<div class="file-icon {fileIconKindClass(iconName)}">
|
||||
<!-- Colour type icon is always rendered; a successful thumbnail covers it
|
||||
edge-to-edge, and a failed one (onerror hides the <img>) reveals it. -->
|
||||
<Icon name={iconName} />
|
||||
{#if canThumbnail(file)}
|
||||
<img
|
||||
class="file-thumb"
|
||||
@@ -1573,8 +1628,6 @@
|
||||
loading="lazy"
|
||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')}
|
||||
/>
|
||||
{:else}
|
||||
<Icon name={iconNameFromClass(file.icon_class)} />
|
||||
{/if}
|
||||
</div>
|
||||
<span title={file.name}>{file.name}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
addUserMember,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupDescription,
|
||||
groupDisplayName,
|
||||
groupIconName,
|
||||
INTERNAL_GROUP_ID,
|
||||
@@ -243,6 +244,7 @@
|
||||
{:else}
|
||||
<ul class="list">
|
||||
{#each groups as g (g.id)}
|
||||
{@const description = groupDescription(g)}
|
||||
<li class="group">
|
||||
<div class="group__row">
|
||||
<button class="group__name" onclick={() => expand(g)}>
|
||||
@@ -254,7 +256,7 @@
|
||||
>{t('groups.virtual_badge', 'System')}</span
|
||||
>{/if}
|
||||
</span>
|
||||
{#if g.description}<span class="muted">{g.description}</span>{/if}
|
||||
{#if description}<span class="muted">{description}</span>{/if}
|
||||
{#if !g.is_virtual && g.member_count != null}
|
||||
<span class="muted">{memberCountLabel(g.member_count)}</span>
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
|
||||
const query = $derived(page.url.searchParams.get('q') ?? '');
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon"><Icon name="folder" /></span>
|
||||
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
||||
<span>{folder.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{folder.path}</div>
|
||||
@@ -283,7 +283,9 @@
|
||||
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
|
||||
<span class="file-icon {fileIconKindClass(iconNameFromClass(file.icon_class))}"
|
||||
><Icon name={iconNameFromClass(file.icon_class)} /></span
|
||||
>
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{file.path}</div>
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "سلة المهملات",
|
||||
"sharedwithme": "مشتركة معي",
|
||||
"profile": "الملف الشخصي",
|
||||
"shared_with_me": "مشتركة معي"
|
||||
"shared_with_me": "مشتركة معي",
|
||||
"groups": "المجموعات"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "لا توجد صور بعد",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Papierkorb",
|
||||
"sharedwithme": "Mit mir geteilt",
|
||||
"profile": "Profil",
|
||||
"shared_with_me": "Mit mir geteilt"
|
||||
"shared_with_me": "Mit mir geteilt",
|
||||
"groups": "Gruppen"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Noch keine Fotos",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Papelera",
|
||||
"sharedwithme": "Compartidos conmigo",
|
||||
"profile": "Perfil",
|
||||
"shared_with_me": "Compartidos conmigo"
|
||||
"shared_with_me": "Compartidos conmigo",
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Aún no hay fotos",
|
||||
@@ -365,7 +366,8 @@
|
||||
"new_folder": "Nueva carpeta",
|
||||
"share": "Compartir",
|
||||
"view": "Ver",
|
||||
"selected_count": "{{count}} seleccionados"
|
||||
"selected_count": "{{count}} seleccionados",
|
||||
"uploaded_saved": "Subida completa — {{mb}} MB deduplicados"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "سطل زباله",
|
||||
"sharedwithme": "به اشتراکگذاشته شده با من",
|
||||
"profile": "پروفایل",
|
||||
"shared_with_me": "به اشتراکگذاشته شده با من"
|
||||
"shared_with_me": "به اشتراکگذاشته شده با من",
|
||||
"groups": "گروهها"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "هنوز عکسی نیست",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Corbeille",
|
||||
"sharedwithme": "Partages avec moi",
|
||||
"profile": "Profil",
|
||||
"shared_with_me": "Partages avec moi"
|
||||
"shared_with_me": "Partages avec moi",
|
||||
"groups": "Groupes"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Pas encore de photos",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "रद्दी",
|
||||
"sharedwithme": "मेरे साथ साझा किए गए",
|
||||
"profile": "प्रोफ़ाइल",
|
||||
"shared_with_me": "मेरे साथ साझा किए गए"
|
||||
"shared_with_me": "मेरे साथ साझा किए गए",
|
||||
"groups": "समूह"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "अभी कोई फ़ोटो नहीं",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Cestino",
|
||||
"sharedwithme": "Condivisi con me",
|
||||
"profile": "Profilo",
|
||||
"shared_with_me": "Condivisi con me"
|
||||
"shared_with_me": "Condivisi con me",
|
||||
"groups": "Gruppi"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nessuna foto ancora",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "ゴミ箱",
|
||||
"sharedwithme": "自分と共有",
|
||||
"profile": "プロフィール",
|
||||
"shared_with_me": "自分と共有"
|
||||
"shared_with_me": "自分と共有",
|
||||
"groups": "グループ"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "写真はまだありません",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "휴지통",
|
||||
"sharedwithme": "나와 공유됨",
|
||||
"profile": "프로필",
|
||||
"shared_with_me": "나와 공유됨"
|
||||
"shared_with_me": "나와 공유됨",
|
||||
"groups": "그룹"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "아직 사진이 없습니다",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Prullenbak",
|
||||
"sharedwithme": "Gedeeld met mij",
|
||||
"profile": "Profiel",
|
||||
"shared_with_me": "Gedeeld met mij"
|
||||
"shared_with_me": "Gedeeld met mij",
|
||||
"groups": "Groepen"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nog geen foto's",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Kosz",
|
||||
"sharedwithme": "Udostępnione dla mnie",
|
||||
"profile": "Profil",
|
||||
"shared_with_me": "Udostępnione dla mnie"
|
||||
"shared_with_me": "Udostępnione dla mnie",
|
||||
"groups": "Grupy"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Brak zdjęć",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Lixeira",
|
||||
"sharedwithme": "Compartilhados comigo",
|
||||
"profile": "Perfil",
|
||||
"shared_with_me": "Compartilhados comigo"
|
||||
"shared_with_me": "Compartilhados comigo",
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Nenhuma foto ainda",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "Корзина",
|
||||
"sharedwithme": "Доступно мне",
|
||||
"profile": "Профиль",
|
||||
"shared_with_me": "Доступно мне"
|
||||
"shared_with_me": "Доступно мне",
|
||||
"groups": "Группы"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "Фотографий пока нет",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "回收站",
|
||||
"sharedwithme": "與我共享",
|
||||
"profile": "個人資料",
|
||||
"shared_with_me": "與我共享"
|
||||
"shared_with_me": "與我共享",
|
||||
"groups": "群組"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "還沒有照片",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"trash": "回收站",
|
||||
"sharedwithme": "与我共享",
|
||||
"profile": "个人资料",
|
||||
"shared_with_me": "与我共享"
|
||||
"shared_with_me": "与我共享",
|
||||
"groups": "群组"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "还没有照片",
|
||||
|
||||
@@ -1075,6 +1075,33 @@ impl DedupService {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Batch variant of [`Self::user_owns_blob_reference`]: given candidate
|
||||
/// hashes, return the subset the user already references — in ONE query
|
||||
/// (backed by `idx_files_blob_hash`). Lets a client hash a whole upload set
|
||||
/// and learn which files it can skip with a single round trip instead of
|
||||
/// one probe per file.
|
||||
///
|
||||
/// User-scoped, exactly like the single check: only the caller's own blobs
|
||||
/// are returned, so it cannot probe whether *other* users hold a blob.
|
||||
pub async fn user_owned_blob_references(
|
||||
&self,
|
||||
hashes: &[String],
|
||||
user_id: &str,
|
||||
) -> Vec<String> {
|
||||
if hashes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT blob_hash FROM storage.files \
|
||||
WHERE blob_hash = ANY($1) AND user_id = $2::uuid",
|
||||
)
|
||||
.bind(hashes)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get metadata for a blob (manifest-aware with legacy fallback).
|
||||
pub async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto> {
|
||||
// Check manifest first
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Path, State},
|
||||
extract::{Json, Multipart, Path, State},
|
||||
http::{Response, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
@@ -15,6 +15,17 @@ use std::sync::Arc;
|
||||
/// Global application state for dependency injection
|
||||
type GlobalState = Arc<AppState>;
|
||||
|
||||
/// Upper bound on hashes accepted in one batch ownership check — keeps a single
|
||||
/// request from pinning the DB with a pathologically large `ANY(...)` array.
|
||||
/// ~10k covers any realistic folder upload; clients fall back to plain uploads
|
||||
/// for whatever doesn't fit.
|
||||
const MAX_BATCH_HASHES: usize = 10_000;
|
||||
|
||||
/// A well-formed BLAKE3 hash is 64 hex characters.
|
||||
fn is_valid_blob_hash(hash: &str) -> bool {
|
||||
hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Response for hash check endpoint
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HashCheckResponse {
|
||||
@@ -32,6 +43,20 @@ pub struct HashCheckResponse {
|
||||
pub ref_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Request body for the batch hash-ownership check (`POST /api/dedup/check-batch`).
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct HashBatchRequest {
|
||||
/// Candidate BLAKE3 hashes (64 hex chars each) to test for ownership.
|
||||
pub hashes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response for the batch hash-ownership check.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct HashBatchResponse {
|
||||
/// The subset of the submitted `hashes` the authenticated user already owns.
|
||||
pub owned: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response for upload with dedup endpoint
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DedupUploadResponse {
|
||||
@@ -98,7 +123,7 @@ impl DedupHandler {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Validate hash format (BLAKE3 = 64 hex chars)
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !is_valid_blob_hash(&hash) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
@@ -152,6 +177,52 @@ impl DedupHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch variant of [`Self::check_hash_impl`]: return the subset of the
|
||||
/// submitted hashes the authenticated user already owns, in one round trip.
|
||||
/// Lets a client hash a whole upload set up front and learn which files it
|
||||
/// can skip with ONE request instead of one probe per file.
|
||||
///
|
||||
/// User-scoped (anti-enumeration): only the caller's own blobs are echoed
|
||||
/// back — never reveals whether other users hold a blob. Malformed hashes
|
||||
/// are silently dropped (they can't be owned anyway).
|
||||
///
|
||||
/// POST /api/dedup/check-batch
|
||||
pub(super) async fn check_hashes_batch_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
Json(request): Json<HashBatchRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if request.hashes.len() > MAX_BATCH_HASHES {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Too many hashes in one batch"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let valid: Vec<String> = request
|
||||
.hashes
|
||||
.into_iter()
|
||||
.filter(|h| is_valid_blob_hash(h))
|
||||
.collect();
|
||||
|
||||
let owned = state
|
||||
.core
|
||||
.dedup_service
|
||||
.user_owned_blob_references(&valid, &auth_user.id.to_string())
|
||||
.await;
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&HashBatchResponse { owned }).unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Upload content with automatic deduplication (streaming).
|
||||
///
|
||||
/// Streams the multipart field straight into the CDC chunk store —
|
||||
@@ -313,7 +384,7 @@ impl DedupHandler {
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Validate hash format
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !is_valid_blob_hash(&hash) {
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
@@ -472,6 +543,25 @@ pub async fn check_hash(
|
||||
DedupHandler::check_hash_impl(state, auth_user, path).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/dedup/check-batch",
|
||||
request_body = HashBatchRequest,
|
||||
responses(
|
||||
(status = 200, description = "The subset of the submitted hashes the caller already owns", body = HashBatchResponse),
|
||||
(status = 400, description = "Too many hashes in one batch"),
|
||||
),
|
||||
tag = "dedup",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn check_hashes_batch(
|
||||
state: State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
body: Json<HashBatchRequest>,
|
||||
) -> impl IntoResponse {
|
||||
DedupHandler::check_hashes_batch_impl(state, auth_user, body).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/dedup/upload",
|
||||
@@ -751,4 +841,30 @@ mod tests {
|
||||
assert_eq!(parsed["bytes_saved"], 2048);
|
||||
assert_eq!(parsed["ref_count"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_blob_hash_accepts_64_hex_only() {
|
||||
assert!(is_valid_blob_hash(&"abcdef0123456789".repeat(4))); // 64 hex chars
|
||||
assert!(!is_valid_blob_hash(&"a".repeat(63))); // too short
|
||||
assert!(!is_valid_blob_hash(&"a".repeat(65))); // too long
|
||||
assert!(!is_valid_blob_hash(&"g".repeat(64))); // non-hex
|
||||
assert!(!is_valid_blob_hash("")); // empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_batch_request_deserializes() {
|
||||
let req: HashBatchRequest = serde_json::from_str(r#"{"hashes":["aa","bb"]}"#).unwrap();
|
||||
assert_eq!(req.hashes, vec!["aa".to_string(), "bb".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_batch_response_serializes_owned_subset() {
|
||||
let r = HashBatchResponse {
|
||||
owned: vec!["a".repeat(64), "b".repeat(64)],
|
||||
};
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
|
||||
assert_eq!(parsed["owned"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(parsed["owned"][0], "a".repeat(64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,10 +391,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// All handlers are free functions — see dedup_handler.rs for why
|
||||
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
|
||||
use super::handlers::dedup_handler::{
|
||||
check_hash, get_blob, get_stats, recalculate_stats, upload_with_dedup,
|
||||
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats, upload_with_dedup,
|
||||
};
|
||||
let dedup_router = Router::new()
|
||||
.route("/check/{hash}", get(check_hash))
|
||||
.route("/check-batch", post(check_hashes_batch))
|
||||
.route("/upload", post(upload_with_dedup))
|
||||
.route("/stats", get(get_stats))
|
||||
.route("/blob/{hash}", get(get_blob))
|
||||
|
||||
Reference in New Issue
Block a user