Files
Oxicloud/frontend/src/lib/api/endpoints/files.ts
T
Claude 50eca0627f perf: round 12 — auth write-path narrowing, fused quota gate, moka blob-cache index, media single-read, sized listing JSON
Benchmark-gated round (benches/ROUND12.md; every change ships with a
BEFORE/AFTER harness + equivalence gates, one candidate rejected by its
own bench):

DB / query shapes (bench_round12_queries):
- NC sharee search: username-only projection instead of the 21-column row
  (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on
  auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index).
- Password login: delete the redundant full-row update_user — create_session
  already stamps last_login_at in its own txn (4.45x per login).
- Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login
  now compares profile state in memory and issues ZERO queries when nothing
  changed (was: full 17-column rewrite per login).
- Refresh rotation: revoke+insert+stamp fused into one transaction via new
  rotate_session port method (1.18x).
- WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file +
  check(Update) overlapped with tokio::join!, original result precedence
  (cold 1.34x).
- Upload quota gate: user-envelope + drive-cap checks fused into ONE
  round-trip (check_upload_quotas) — the NC chunked PUT pays this per
  chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep
  error shapes byte-identical.

CPU / allocs (bench_round12_micro):
- sized_json: pre-sized listing serialization replacing axum Json's 128 B
  seed + doubling-realloc chain on files/folder-resources/photos/search
  responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical).
- Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware
  pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated
  byte-identical incl. 304s).
- Media capture-metadata: single-read extraction — nom-exif now parses the
  buffer kamadak already read (zero-copy Bytes) and videos open once with a
  kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x
  cold cache; extraction outputs gated identical incl. the MIME-mislabel
  track fallback).
- Chunked-upload session ops: owner gate folded into the operation's own
  DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs,
  1.28x per chunk).

Blob cache (bench_blob_cache_index + round-3 regression guard):
- CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache
  with byte weigher. The mutex serialized every cached chunk read and scaled
  NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are
  lock-free (2.17x at K=2). Byte budget now enforced by moka (manual
  current_size + collect_evictions machinery deleted); eviction listener
  unlinks size-evicted files only (Replaced entries keep their file —
  gated). Single-flight miss gate unchanged (16 concurrent misses -> 1
  fetch re-verified via the round-3 harness).
- put_blob now populates the cache BEFORE the inner backend consumes the
  source file (the old order failed 100% of the time — local renames,
  S3/Azure delete the source — so the first read after a whole-file put
  re-downloaded from the remote); inner-put failure invalidates the entry.

Frontend (vitest gates):
- List-view thumbnails request the 150px icon rendition instead of 400px
  preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per
  thumbnail across list views); grid keeps preview.

Rejected by its own bench (kept as evidence in bench_round12_micro §2):
- Single-pass compression predicate: the monomorphized And-chain already
  costs ~4.6 ns / 0 allocs total; the fused node measured within noise.

New migration: 20260719000000_users_search_trgm.sql (trgm indexes).
Deferred with prepared design: grouped file/grid view virtualization
(single-VirtualRows flatten, the photos pattern) — next round's headline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
2026-07-19 01:32:00 +00:00

176 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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);
// Self-aborting watchdog so a stalled connection can never pin an upload
// slot forever (and leave a zombie XHR holding one of the browser's few
// per-host connections). While the body is uploading we reset the deadline
// on every progress tick — a slow but *moving* transfer is fine; once the
// body is fully sent we give the server a fixed window to respond. On a
// stall we `xhr.abort()`, which frees the connection immediately.
const SEND_STALL_MS = 30_000;
const RESPONSE_MS = 60_000;
let watchdog: ReturnType<typeof setTimeout>;
const arm = (ms: number) => {
clearTimeout(watchdog);
watchdog = setTimeout(() => xhr.abort(), ms);
};
xhr.upload.onprogress = (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
arm(SEND_STALL_MS);
};
xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server
xhr.onload = () => {
clearTimeout(watchdog);
if (xhr.status >= 200 && xhr.status < 300) resolve();
else {
// Flag quota so a batch can stop early instead of retrying every file.
const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean };
err.isQuota = xhr.status === 507;
reject(err);
}
};
xhr.onerror = () => {
clearTimeout(watchdog);
reject(new Error('upload failed: network error'));
};
xhr.onabort = () => {
clearTimeout(watchdog);
reject(new Error('upload stalled — aborted'));
};
arm(SEND_STALL_MS);
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}`;
}
/**
* Thumbnail size matched to the rendering slot. List rows draw thumbnails in
* a 40×40 box, so the 150px `icon` rendition is already ≥2× retina density —
* fetching the 400px `preview` there moved ~7× more pixels than the slot can
* show (benches/ROUND12.md §F1). Grid cards (100×70 slot) keep `preview`.
*/
export function thumbSizeForView(view: 'grid' | 'list'): 'icon' | 'preview' {
return view === 'list' ? 'icon' : 'preview';
}