Files
Oxicloud/frontend/src/lib/api/endpoints/files.ts
T
DioCrafts d98e3117b2 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>
2026-06-20 16:33:08 +02:00

135 lines
4.7 KiB
TypeScript

/** File endpoints — ported from fileOperations.js. */
import { apiFetch } from '$lib/api/client';
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);
form.append('file', file);
const res = await apiFetch('/api/files/upload', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: getCsrfHeaders(), // multipart boundary set automatically; do not set Content-Type
body: form
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
}
/**
* Upload with progress reporting. `fetch` can't surface upload progress, so this
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
*/
export function uploadFileWithProgress(
folderId: string | null,
file: File,
onProgress: (fraction: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const form = new FormData();
if (folderId) form.append('folder_id', folderId);
form.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files/upload');
xhr.withCredentials = true;
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('upload failed: network error'));
xhr.send(form);
});
}
export async function renameFile(fileId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename file failed: ${res.status}`);
}
export async function moveFile(fileId: string, targetFolderId: string | null): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/move`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ folder_id: targetFolderId || null })
});
if (!res.ok) throw new Error(`move file failed: ${res.status}`);
}
export async function deleteFile(fileId: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete file failed: ${res.status}`);
}
export function fileDownloadUrl(fileId: string): string {
return `/api/files/${fileId}`;
}
export function fileInlineUrl(fileId: string): string {
return `/api/files/${fileId}?inline=true`;
}
/** Thumbnail URL for a file at the given size (server-rendered, content-typed). */
export function fileThumbnailUrl(
fileId: string,
size: 'icon' | 'preview' | 'large' = 'preview'
): string {
return `/api/files/${fileId}/thumbnail/${size}`;
}