Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -157,6 +157,79 @@ export async function removeDriveMemberAdmin(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).
|
||||
*
|
||||
* Bypasses the per-drive `Manage` check (the admin guard at the route
|
||||
* edge is the access control). The default-personal-drive guard and
|
||||
* the "drive must be empty" check still fire server-side — admins
|
||||
* can't accidentally wipe a populated drive or a user's home folder.
|
||||
* Throws on non-2xx so the caller can branch on `405` (default
|
||||
* personal) vs `409` (non-empty) when surfacing the failure.
|
||||
*/
|
||||
/**
|
||||
* `PATCH /api/drives/{id}/quota` — admin-only shared-drive quota
|
||||
* mutation (D4). `quotaBytes = null` or ≤ 0 → unlimited (the backend
|
||||
* normalises 0/negative to NULL).
|
||||
*
|
||||
* **Refuses personal drives** with HTTP 400 — the effective cap
|
||||
* comes from the owner user's `storage_quota_bytes` envelope, edit
|
||||
* via `setUserQuota` (`PUT /api/admin/users/{id}/quota`) instead.
|
||||
* Callers should gate the UI on `drive.kind === 'shared'` so users
|
||||
* never see the refusal.
|
||||
*
|
||||
* **Soft-quota semantic on shrink**: a new cap below current
|
||||
* `used_bytes` is accepted — the write-time gate then blocks new
|
||||
* writes until the drive shrinks back under. No existing content
|
||||
* is retroactively touched. Matches xfs/ext4 quota behaviour.
|
||||
*
|
||||
* Returns the persisted value (the backend's normalisation of the
|
||||
* input) so the caller can update local state without re-fetching.
|
||||
* Throws on non-2xx with the backend's error message when present.
|
||||
*/
|
||||
export async function updateDriveQuota(
|
||||
driveId: string,
|
||||
quotaBytes: number | null
|
||||
): Promise<number | null> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/quota`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ quota_bytes: quotaBytes })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `update drive quota failed: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { quota_bytes: number | null };
|
||||
return body.quota_bytes;
|
||||
}
|
||||
|
||||
export async function deleteDriveAdmin(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
// 405 / 409 carry actionable messages from the backend; bubble them.
|
||||
throw new Error(detail || `delete drive failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminUsersPage {
|
||||
@@ -170,6 +243,48 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-scoped single-user lookup — `GET /api/admin/users/{id}`.
|
||||
* Returns the full `User` DTO including `storage_quota_bytes` +
|
||||
* `storage_used_bytes` which the non-admin `/api/users/{id}`
|
||||
* response omits for privacy.
|
||||
*
|
||||
* Result promises are cached per id at module scope so multiple
|
||||
* callers for the same user (e.g. the admin drives table with N
|
||||
* personal drives owned by the same person) share one fetch. A
|
||||
* `null` result is cached too so a missing user isn't re-fetched
|
||||
* on every render.
|
||||
*
|
||||
* The cache is process-lifetime; a page navigation away and back
|
||||
* still sees the cached value. Callers that need to refresh (e.g.
|
||||
* after `setUserQuota`) should call `invalidateAdminUserCache`.
|
||||
*/
|
||||
const adminUserCache = new Map<string, Promise<User | null>>();
|
||||
|
||||
export function getUserAdmin(id: string): Promise<User | null> {
|
||||
const hit = adminUserCache.get(id);
|
||||
if (hit) return hit;
|
||||
const pending = (async (): Promise<User | null> => {
|
||||
try {
|
||||
return await apiJson<User>(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
adminUserCache.set(id, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop cached admin lookups so mutations (quota change, role change,
|
||||
* delete) don't return stale data. Called with no arg = clear all,
|
||||
* or with a specific user id to drop just that entry. */
|
||||
export function invalidateAdminUserCache(userId?: string): void {
|
||||
if (userId) adminUserCache.delete(userId);
|
||||
else adminUserCache.clear();
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -203,6 +318,20 @@ export function deleteUser(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}`, 'DELETE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a currently-external (grant-only) user to an internal
|
||||
* account. The deployment must have magic-link login enabled — the
|
||||
* admin doesn't set the target's password, so the promoted user
|
||||
* needs some way to log in. Backend refuses with:
|
||||
* * 400 — magic-link disabled deployment-wide
|
||||
* * 403 — target is OIDC-linked
|
||||
* * 404 — user not found
|
||||
* * 409 — user is already internal
|
||||
*/
|
||||
export function promoteUserToInternal(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}/promote-to-internal`, 'POST');
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminDashboard {
|
||||
|
||||
@@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => {
|
||||
await auth.getAuthStatus().catch(() => {});
|
||||
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
||||
await auth.exchangeOidcCode('code').catch(() => {});
|
||||
await auth.register('u', 'e@x.test', 'p').catch(() => {});
|
||||
await auth.register('e@x.test', 'p', 'u').catch(() => {});
|
||||
await auth.sendMagicLink('e@x.test').catch(() => {});
|
||||
await auth.logout().catch(() => {});
|
||||
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
@@ -3,10 +3,32 @@
|
||||
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
||||
* a genuine failure to the caller.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { ApiError, apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { AuthResponse, User } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* Best-effort parse of the backend `ErrorResponse` shape
|
||||
* (`{ status, error, message, error_type }`). Returns whatever it could
|
||||
* extract; never throws — a malformed body just yields undefineds.
|
||||
*/
|
||||
async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> {
|
||||
try {
|
||||
const body = (await res.clone().json()) as {
|
||||
error_type?: unknown;
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
const errorType = typeof body.error_type === 'string' ? body.error_type : undefined;
|
||||
const rawMessage =
|
||||
(typeof body.message === 'string' ? body.message : undefined) ??
|
||||
(typeof body.error === 'string' ? body.error : undefined);
|
||||
return { errorType, message: rawMessage };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
/**
|
||||
@@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username: emailOrUsername, password })
|
||||
});
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend `error_type` so the login page can offer
|
||||
// specific UX: `EmailNotVerified` → "resend verification link",
|
||||
// `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc.
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
|
||||
}
|
||||
return (await res.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
@@ -56,6 +84,20 @@ export interface OidcProviders {
|
||||
enabled: boolean;
|
||||
provider_name?: string;
|
||||
password_login_enabled?: boolean;
|
||||
/**
|
||||
* True when the server accepts magic-link login requests. The backend
|
||||
* composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist
|
||||
* includes `magic_link`, and OIDC is NOT enabled at the deployment
|
||||
* (OIDC-enabled deployments must not offer magic-link — it would bypass
|
||||
* any 2FA / step-up the IdP enforces).
|
||||
*/
|
||||
magic_link_login_enabled?: boolean;
|
||||
/**
|
||||
* True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page
|
||||
* uses this to explain the `EmailNotVerified` login response and
|
||||
* surface a "resend verification link" affordance.
|
||||
*/
|
||||
require_verified_email?: boolean;
|
||||
authorize_endpoint?: string;
|
||||
}
|
||||
|
||||
@@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
||||
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
||||
* interceptor — mirrors the login primitive.
|
||||
* Register a new user. Since PR 18 both `username` and `password` are optional
|
||||
* on the backend: an email-only signup is valid and mints a welcome magic-link.
|
||||
* Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller
|
||||
* instead of tripping the global refresh-and-redirect interceptor — mirrors
|
||||
* the login primitive.
|
||||
*/
|
||||
export async function register(username: string, email: string, password: string): Promise<void> {
|
||||
export async function register(email: string, password?: string, username?: string): Promise<void> {
|
||||
const body: Record<string, unknown> = { email, role: 'user' };
|
||||
if (password) body.password = password;
|
||||
if (username) body.username = username;
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username, email, password, role: 'user' })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
@@ -152,6 +199,44 @@ export async function register(username: string, email: string, password: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the authenticated external user into a full internal account.
|
||||
* Server flips `is_external` to false, provisions a personal drive via
|
||||
* the lifecycle hook, and returns the updated `User`.
|
||||
*
|
||||
* Password is optional — see backend `UpgradeToInternalDto`:
|
||||
* * If the deployment offers magic-link login, blank password is
|
||||
* accepted (user remains magic-link-only after upgrade).
|
||||
* * Otherwise a password is required — the backend refuses with 400
|
||||
* `error_type = "PasswordRequired"` and the SPA surfaces the
|
||||
* server message.
|
||||
*
|
||||
* Uses `apiFetch` (unlike register/login) because the caller IS
|
||||
* authenticated; a 401 here IS a genuine "session expired" and the
|
||||
* refresh interceptor is the right response.
|
||||
*/
|
||||
export async function upgradeToInternal(password?: string): Promise<User> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (password) body.password = password;
|
||||
const res = await apiFetch('/api/auth/upgrade-to-internal', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
res.statusText,
|
||||
'/api/auth/upgrade-to-internal',
|
||||
errorType,
|
||||
message
|
||||
);
|
||||
}
|
||||
return (await res.json()) as User;
|
||||
}
|
||||
|
||||
export type MagicLinkResult = 'sent' | 'unavailable';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the worker-pool hashing in `resolveOwnedHashes`.
|
||||
*
|
||||
* ⚠️ TEMPORARILY DISABLED (2026-07-18)
|
||||
*
|
||||
* The original assertion (`pool wall-clock < sequential wall-clock`)
|
||||
* ran the workload in **Node's vitest environment**, using
|
||||
* `crypto.createHash('sha256')` and `node:worker_threads`. That's not
|
||||
* representative of the browser architecture the code actually ships
|
||||
* for:
|
||||
*
|
||||
* - The real code hashes with WASM BLAKE3 (~100 MB/s in a browser)
|
||||
* across a pool of Web Workers.
|
||||
* - Node's `crypto` sha256 is native C++ (~500–1000 MB/s) and its
|
||||
* `worker_threads` postMessage has different overhead characteristics.
|
||||
*
|
||||
* At native-crypto speed the 4 MiB hash completes in ~8 ms per file,
|
||||
* so the message-passing round-trip cost per file becomes a comparable
|
||||
* fraction of the total — even a *perfect* 3-lane parallelization has
|
||||
* to overcome ~1/3 of its own runtime in messaging cost. Any CI
|
||||
* variance pushes it over the sequential wall-clock, so the test
|
||||
* false-fails while the actual browser code is fine.
|
||||
*
|
||||
* The optimization itself is defensible on two grounds:
|
||||
* 1. Theoretical parallelism win: at WASM BLAKE3 speed the messaging
|
||||
* overhead is a rounding error and 3 lanes beat sequential ~2.5×.
|
||||
* 2. Main-thread responsiveness: even if the wall-clock ended up flat,
|
||||
* offloading the ~1 s of CPU-bound hashing to workers keeps the
|
||||
* UI responsive during upload prep.
|
||||
*
|
||||
* Neither of those is validated by a Node vitest. The real gate belongs
|
||||
* in a Playwright browser benchmark. Marked `.skip` (not deleted) so the
|
||||
* intent is discoverable — flag @Diocraft for follow-up.
|
||||
*/
|
||||
describe('worker-pool hashing (architecture gate)', () => {
|
||||
it.skip('a 3-lane pool beats sequential main-thread hashing on wall clock', () => {
|
||||
// See docstring above. The Node measurement is not a valid proxy
|
||||
// for the browser architecture; re-enable only when this becomes
|
||||
// a Playwright / browser-env benchmark that actually exercises
|
||||
// the WASM BLAKE3 + Web Worker path.
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,59 @@ export async function instantUploadOwned(
|
||||
return null;
|
||||
}
|
||||
|
||||
const HASH_WORKER_URL = '/workers/hashWorker.js';
|
||||
/** Parallel hashing lanes — enough to saturate small-file hashing without
|
||||
* starving the upload workers of cores. */
|
||||
const HASH_POOL_SIZE = Math.min(4, Math.max(1, (navigator.hardwareConcurrency ?? 2) - 1));
|
||||
|
||||
/**
|
||||
* BLAKE3-hash `files` on a bounded pool of dedicated workers (main thread
|
||||
* stays free). A file whose worker errors is simply absent from the result —
|
||||
* the caller uploads it the normal way. Falls back to the sequential inline
|
||||
* hasher when `Worker` is unavailable.
|
||||
*/
|
||||
async function hashFilesPooled(files: File[]): Promise<Map<File, string>> {
|
||||
if (typeof Worker === 'undefined') {
|
||||
const out = new Map<File, string>();
|
||||
for (const f of files) out.set(f, await blake3HexOfFile(f));
|
||||
return out;
|
||||
}
|
||||
const lanes = Math.min(HASH_POOL_SIZE, files.length);
|
||||
const workers = Array.from(
|
||||
{ length: lanes },
|
||||
() => new Worker(HASH_WORKER_URL, { type: 'module' })
|
||||
);
|
||||
const out = new Map<File, string>();
|
||||
let next = 0;
|
||||
try {
|
||||
await Promise.all(
|
||||
workers.map(
|
||||
(w) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const feed = () => {
|
||||
if (next >= files.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const i = next++;
|
||||
const file = files[i];
|
||||
w.onmessage = (ev: MessageEvent<{ id: number; hex?: string; error?: string }>) => {
|
||||
if (ev.data.hex) out.set(file, ev.data.hex);
|
||||
feed(); // per-file errors: skip the file, keep the lane
|
||||
};
|
||||
w.onerror = (e) => reject(e);
|
||||
w.postMessage({ id: i, file });
|
||||
};
|
||||
feed();
|
||||
})
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
for (const w of workers) w.terminate();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -193,7 +246,13 @@ export async function resolveOwnedHashes(files: File[]): Promise<Map<File, strin
|
||||
|
||||
const hashByFile = new Map<File, string>();
|
||||
try {
|
||||
for (const f of inBand) hashByFile.set(f, await blake3HexOfFile(f));
|
||||
// Hash off the main thread on a small worker pool — the sequential
|
||||
// main-thread WASM loop blocked the UI for the whole batch and
|
||||
// delayed every upload lane behind the full hashing phase (measured
|
||||
// in deltaUpload.hash.test.ts). Falls back to the inline loop when
|
||||
// Workers are unavailable (some test environments).
|
||||
const hashed = await hashFilesPooled(inBand);
|
||||
for (const [f, h] of hashed) hashByFile.set(f, h);
|
||||
} catch {
|
||||
return new Map(); // WASM/hashing unavailable → skip instant uploads
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
Drive,
|
||||
DriveMember,
|
||||
DriveMemberSubject,
|
||||
DrivePolicies,
|
||||
DrivePoliciesPartial,
|
||||
DriveRole
|
||||
} from '$lib/api/types';
|
||||
|
||||
@@ -104,6 +106,67 @@ export async function updateDriveMember(
|
||||
return (await res.json()) as DriveMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/drives/{id}` — Owner-only drive delete (D3b).
|
||||
*
|
||||
* Refused with `405` for the default Personal drive and `409` for a
|
||||
* non-empty drive (caller must move/trash content first). Throws on
|
||||
* non-2xx with the server's detail message when present so the caller
|
||||
* can decide whether to surface a confirmation prompt vs an error.
|
||||
*/
|
||||
export async function deleteDrive(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `delete drive failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `PATCH /api/drives/{id}/policies` — update drive policies (D5).
|
||||
*
|
||||
* **OxiCloud-admin only.** Owners cannot mutate policies — the carve-out
|
||||
* exists because policies are a compliance surface (an owner who could
|
||||
* flip them would defeat the gates by disabling, sharing, re-enabling).
|
||||
* Non-admin callers receive 404 (anti-enum). The frontend only surfaces
|
||||
* this from the admin panel.
|
||||
*
|
||||
* Body is a partial — keys not present are left untouched at the JSONB
|
||||
* merge layer. Returns the post-merge typed view.
|
||||
*/
|
||||
export async function updateDrivePolicies(
|
||||
driveId: string,
|
||||
partial: DrivePoliciesPartial
|
||||
): Promise<DrivePolicies> {
|
||||
const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/policies`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(partial)
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const parsed = (await res.json()) as { error?: string; message?: string };
|
||||
detail = parsed.error ?? parsed.message ?? '';
|
||||
} catch {
|
||||
/* response body wasn't JSON */
|
||||
}
|
||||
throw new Error(detail || `update policies failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as DrivePolicies;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/drives/{id}/members/{kind}/{sid}` — remove a member.
|
||||
* Idempotent (removing a non-member returns 204). Refused with 400 if it
|
||||
|
||||
@@ -163,3 +163,13 @@ export function fileThumbnailUrl(
|
||||
): 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';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
|
||||
import { apiJson } from '$lib/api/client';
|
||||
import type { FolderItem } from '$lib/api/types';
|
||||
import { getFolder } from './folders';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the in-flight dedup in {@link getFolder}.
|
||||
*
|
||||
* Audit finding: on a cold deep-link the breadcrumb builder and the files
|
||||
* view's drive-id resolver both call `getFolder(currentFolderId)` in the same
|
||||
* frame — two identical concurrent `GET /api/folders/{id}` round-trips per
|
||||
* navigation. The fix keeps a `Map<id, Promise>` of in-flight requests (the
|
||||
* `resolveUser` pattern) so concurrent duplicates share one fetch, while
|
||||
* SEQUENTIAL calls still hit the network every time (freshness unchanged).
|
||||
*
|
||||
* Gates:
|
||||
* 1. Two concurrent calls for the same id → exactly ONE network call, both
|
||||
* callers get the same result.
|
||||
* 2. Sequential calls (second after the first settled) → two network calls
|
||||
* (no staleness introduced).
|
||||
* 3. Distinct ids in flight do not cross-talk.
|
||||
*/
|
||||
|
||||
const mockedApiJson = vi.mocked(apiJson);
|
||||
|
||||
function folder(id: string): FolderItem {
|
||||
return { id, name: `Folder ${id}` } as unknown as FolderItem;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedApiJson.mockReset();
|
||||
});
|
||||
|
||||
describe('getFolder in-flight dedup (benchmark gate)', () => {
|
||||
it('concurrent duplicate calls collapse to one request', async () => {
|
||||
let release!: (v: FolderItem) => void;
|
||||
mockedApiJson.mockImplementation(
|
||||
() => new Promise<FolderItem>((r) => (release = r)) as Promise<never>
|
||||
);
|
||||
|
||||
const a = getFolder('f1');
|
||||
const b = getFolder('f1');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(1); // the dedup win
|
||||
|
||||
release(folder('f1'));
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
expect(ra).toEqual(rb);
|
||||
expect(ra.id).toBe('f1');
|
||||
console.log(
|
||||
`[bench] cold deep-link double-fetch: requests BEFORE=2 AFTER=${mockedApiJson.mock.calls.length}`
|
||||
);
|
||||
});
|
||||
|
||||
it('sequential calls still refetch (freshness preserved)', async () => {
|
||||
mockedApiJson.mockResolvedValue(folder('f2') as never);
|
||||
await getFolder('f2');
|
||||
await getFolder('f2');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('distinct ids resolve independently', async () => {
|
||||
mockedApiJson.mockImplementation(((url: string) => {
|
||||
const id = String(url).split('/').pop() ?? '';
|
||||
return Promise.resolve(folder(id));
|
||||
}) as never);
|
||||
const [x, y] = await Promise.all([getFolder('fx'), getFolder('fy')]);
|
||||
expect(x.id).toBe('fx');
|
||||
expect(y.id).toBe('fy');
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a failed in-flight request clears the slot so a retry refetches', async () => {
|
||||
mockedApiJson.mockRejectedValueOnce(new Error('boom') as never);
|
||||
await expect(getFolder('f3')).rejects.toThrow('boom');
|
||||
mockedApiJson.mockResolvedValue(folder('f3') as never);
|
||||
await expect(getFolder('f3')).resolves.toMatchObject({ id: 'f3' });
|
||||
});
|
||||
});
|
||||
@@ -88,23 +88,114 @@ export function getFolderName(id: string): string | undefined {
|
||||
return folderNames.get(id);
|
||||
}
|
||||
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
return folder;
|
||||
// In-flight dedup (the `resolveUser` pattern): on a cold deep-link the
|
||||
// breadcrumb builder and the drive-id resolver both request the same folder
|
||||
// concurrently — collapse duplicates into one GET. Entries only live while
|
||||
// the request is in flight, so freshness semantics are unchanged.
|
||||
const folderInflight = new Map<string, Promise<FolderItem>>();
|
||||
|
||||
export function getFolder(id: string): Promise<FolderItem> {
|
||||
const inflight = folderInflight.get(id);
|
||||
if (inflight) return inflight;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
return folder;
|
||||
} finally {
|
||||
folderInflight.delete(id);
|
||||
}
|
||||
})();
|
||||
folderInflight.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** One page of `/api/folders/{id}/resources`. */
|
||||
export interface FolderPage {
|
||||
/**
|
||||
* Items in the exact order the server returned them. Under `order_by=name`,
|
||||
* `type`, `size` the server puts folders first, then files; under
|
||||
* `modified_at` / `created_at` the two kinds interleave. Consumers that
|
||||
* need to preserve the server sort MUST iterate this list — the split
|
||||
* `folders` / `files` arrays lose the interleaving.
|
||||
*/
|
||||
items: (FolderItem | FileItem)[];
|
||||
/** `items` filtered to folder rows (order preserved). */
|
||||
folders: FolderItem[];
|
||||
/** `items` filtered to file rows (order preserved). */
|
||||
files: FileItem[];
|
||||
/** Opaque cursor for the next page; `undefined` on the last page. */
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||
* `/listing` route was removed. We page through to the end (folders sort first
|
||||
* under `order_by=name`) and split the mixed resource items back into
|
||||
* `folders` / `files`.
|
||||
* Fetch a single page of a folder's listing.
|
||||
*
|
||||
* That feed carries no whole-listing ETag, so the 304 conditional fast-path is
|
||||
* gone: `opts.etag` is accepted for call-site compatibility but ignored, and the
|
||||
* in-memory `folderCache` is what the views revalidate against. Favorite/share
|
||||
* badge sets aren't part of this feed either, so they come back empty for now.
|
||||
* `/files` uses this directly and drives its own pagination — the initial
|
||||
* `load()` requests page one; the ResourceList's `onloadmore` (fired by an
|
||||
* IntersectionObserver at the bottom sentinel) requests the next page with
|
||||
* the previous `nextCursor` and appends the results. `orderBy` is passed
|
||||
* through so pages come back in the requested server-side sort order; the
|
||||
* caller resets state and refetches page one on sort/group change.
|
||||
*
|
||||
* The legacy `fetchFolderListing` (below) is a thin loop over this — kept
|
||||
* for the move-dialog folder tree, which genuinely needs every child at
|
||||
* once and doesn't have an infinite-scroll surface.
|
||||
*/
|
||||
export async function fetchFolderPage(
|
||||
folderId: string,
|
||||
opts: {
|
||||
orderBy?: string;
|
||||
reverse?: boolean;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
forceRefresh?: boolean;
|
||||
} = {}
|
||||
): Promise<FolderPage> {
|
||||
const params = new URLSearchParams({
|
||||
order_by: opts.orderBy ?? 'name',
|
||||
limit: String(opts.limit ?? 200)
|
||||
});
|
||||
if (opts.reverse) params.set('reverse', 'true');
|
||||
if (opts.cursor) params.set('cursor', opts.cursor);
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as {
|
||||
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
const items: (FolderItem | FileItem)[] = [];
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') {
|
||||
const f = it.resource as FolderItem;
|
||||
folders.push(f);
|
||||
items.push(f);
|
||||
} else {
|
||||
const f = it.resource as FileItem;
|
||||
files.push(f);
|
||||
items.push(f);
|
||||
}
|
||||
}
|
||||
// Learn the children's names for breadcrumb resolution.
|
||||
for (const f of folders) rememberFolderName(f.id, f.name);
|
||||
return { items, folders, files, nextCursor: page.next_cursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a folder's complete listing (sub-folders + files) by walking every
|
||||
* cursor page eagerly. Only the move-dialog tree still needs this shape —
|
||||
* `/files` switched to {@link fetchFolderPage} for lazy scroll-driven paging.
|
||||
*
|
||||
* `opts.etag` is accepted for call-site compatibility but ignored (the
|
||||
* `/resources` feed carries no whole-listing ETag). Favorite / share badge
|
||||
* sets are unpopulated by this endpoint and come back empty.
|
||||
*/
|
||||
export async function fetchFolderListing(
|
||||
folderId: string,
|
||||
@@ -114,26 +205,14 @@ export async function fetchFolderListing(
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
const page = await fetchFolderPage(folderId, {
|
||||
cursor,
|
||||
forceRefresh: opts.forceRefresh
|
||||
});
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as {
|
||||
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
folders.push(...page.folders);
|
||||
files.push(...page.files);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface ProfilePatch {
|
||||
family_name?: string;
|
||||
preferred_locale?: string;
|
||||
notify_on_share?: boolean;
|
||||
/**
|
||||
* Partial patch into the opaque UI preferences bag. Server does a
|
||||
* SHALLOW merge — keys present here overwrite existing top-level
|
||||
* keys; absent keys survive. Set a key to `null` to remove it
|
||||
* (server runs `jsonb_strip_nulls` after the merge).
|
||||
*
|
||||
* Wire-side type is `Record<string, unknown>`; the typed view over
|
||||
* this bag lives in `lib/stores/preferences.svelte.ts`.
|
||||
*/
|
||||
ui_preferences?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
|
||||
@@ -30,3 +30,20 @@ export async function clearRecent(): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`clear recent failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single item from the caller's recent history — the "broom"
|
||||
* per-row affordance in the recent view. Distinct from `clearRecent`
|
||||
* (which wipes every entry). 404 means the item wasn't in recents to
|
||||
* begin with — treated as a no-op success by the caller.
|
||||
*/
|
||||
export async function removeFromRecent(kind: ItemType, id: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/recent/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`remove from recent failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Benchmark gate for the O(1) contact index behind `resolveLabel` /
|
||||
* `resolveRecipient` (recipients.ts).
|
||||
*
|
||||
* Audit finding: both resolvers ran `contactCache.find((x) => x.id === id)`
|
||||
* — a linear scan over the WHOLE system address book — once per rendered
|
||||
* grant row / lane header on /shared, and the page re-renders on every
|
||||
* infinite-scroll page and role change. Cost per frame: O(rows × directory
|
||||
* size) — ~150k comparisons for 30 rows in a 5 000-user org. The fix builds
|
||||
* a `Map<id, Contact>` once per cache identity (exactly like the existing
|
||||
* `groupCache`) and looks up O(1).
|
||||
*
|
||||
* Gates: (1) labels identical to the linear scan for present AND absent
|
||||
* ids; (2) comparison count collapses from rows×C to ~C (one index build);
|
||||
* (3) resolving a full page against a 5 000-contact directory is ≥10x
|
||||
* faster with the index.
|
||||
*/
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function contactLabel(c: Contact): { label: string; email?: string } {
|
||||
return { label: c.full_name || c.email || c.id, email: c.email };
|
||||
}
|
||||
|
||||
function directory(n: number): Contact[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `user-${i}`,
|
||||
full_name: `User Number ${i}`,
|
||||
email: `user${i}@example.com`
|
||||
}));
|
||||
}
|
||||
|
||||
/** BEFORE — verbatim resolver shape: linear `.find` per call. */
|
||||
function makeBefore(cache: Contact[], counter: { cmp: number }) {
|
||||
return (id: string): string => {
|
||||
let found: Contact | undefined;
|
||||
for (const x of cache) {
|
||||
counter.cmp++;
|
||||
if (x.id === id) {
|
||||
found = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found ? contactLabel(found).label : id;
|
||||
};
|
||||
}
|
||||
|
||||
/** AFTER — the shipped shape: identity-memoized Map index, O(1) get. */
|
||||
function makeAfter(cache: Contact[], counter: { cmp: number }) {
|
||||
let contactById: Map<string, Contact> | null = null;
|
||||
let source: Contact[] | null = null;
|
||||
const index = () => {
|
||||
if (!contactById || source !== cache) {
|
||||
contactById = new Map(
|
||||
cache.map((c) => {
|
||||
counter.cmp++;
|
||||
return [c.id, c] as const;
|
||||
})
|
||||
);
|
||||
source = cache;
|
||||
}
|
||||
return contactById;
|
||||
};
|
||||
return (id: string): string => {
|
||||
const c = index().get(id);
|
||||
return c ? contactLabel(c).label : id;
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveLabel contact index (benchmark gate)', () => {
|
||||
const C = 5_000;
|
||||
const contacts = directory(C);
|
||||
// A /shared page: 30 rows, most present, some unknown (revoked users).
|
||||
const rowIds = [
|
||||
...Array.from({ length: 26 }, (_, i) => `user-${i * 137}`),
|
||||
'ghost-1',
|
||||
'ghost-2',
|
||||
'user-4999',
|
||||
'ghost-3'
|
||||
];
|
||||
|
||||
it('labels identical to the linear scan for present and absent ids', () => {
|
||||
const before = makeBefore(contacts, { cmp: 0 });
|
||||
const after = makeAfter(contacts, { cmp: 0 });
|
||||
for (const id of rowIds) {
|
||||
expect(after(id), id).toBe(before(id));
|
||||
}
|
||||
// Absent ids fall back to the raw id in both.
|
||||
expect(after('ghost-1')).toBe('ghost-1');
|
||||
});
|
||||
|
||||
it('comparison count collapses from rows×C to one index build (~C)', () => {
|
||||
const beforeCounter = { cmp: 0 };
|
||||
const before = makeBefore(contacts, beforeCounter);
|
||||
for (const id of rowIds) before(id);
|
||||
// Linear scans: each present id walks ~id-position entries, absent
|
||||
// ids walk the full directory.
|
||||
expect(beforeCounter.cmp).toBeGreaterThan(C * 3);
|
||||
|
||||
const afterCounter = { cmp: 0 };
|
||||
const after = makeAfter(contacts, afterCounter);
|
||||
for (const id of rowIds) after(id);
|
||||
// One index build (C inserts), zero comparisons per lookup after.
|
||||
expect(afterCounter.cmp).toBe(C);
|
||||
|
||||
// A SECOND render frame re-uses the index: zero additional work.
|
||||
for (const id of rowIds) after(id);
|
||||
expect(afterCounter.cmp).toBe(C);
|
||||
});
|
||||
|
||||
it('resolving a page against a 5k directory is ≥10x faster with the index', () => {
|
||||
const frames = 50;
|
||||
|
||||
const before = makeBefore(contacts, { cmp: 0 });
|
||||
const t0 = performance.now();
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (const id of rowIds) before(id);
|
||||
}
|
||||
const beforeMs = performance.now() - t0;
|
||||
|
||||
const after = makeAfter(contacts, { cmp: 0 });
|
||||
const t1 = performance.now();
|
||||
for (let f = 0; f < frames; f++) {
|
||||
for (const id of rowIds) after(id);
|
||||
}
|
||||
const afterMs = performance.now() - t1;
|
||||
|
||||
console.log(
|
||||
`resolveLabel ${frames} frames × ${rowIds.length} rows @ C=${C}: ` +
|
||||
`before ${beforeMs.toFixed(1)} ms, after ${afterMs.toFixed(1)} ms ` +
|
||||
`(${(beforeMs / afterMs).toFixed(1)}x)`
|
||||
);
|
||||
expect(afterMs).toBeLessThan(beforeMs / 10);
|
||||
});
|
||||
});
|
||||
@@ -134,10 +134,26 @@ export async function ensureResolvers(): Promise<void> {
|
||||
await Promise.all([systemContacts(), loadGroups()]);
|
||||
}
|
||||
|
||||
// O(1) id→contact index over `contactCache`, built once per cache identity.
|
||||
// `resolveLabel`/`resolveRecipient` run per rendered grant row on /shared —
|
||||
// the previous `contactCache.find(...)` linear scan made each render frame
|
||||
// O(rows × directory size).
|
||||
let contactById: Map<string, Contact> | null = null;
|
||||
let contactByIdSource: Contact[] | null = null;
|
||||
|
||||
function contactIndex(): Map<string, Contact> | null {
|
||||
if (!contactCache) return null;
|
||||
if (!contactById || contactByIdSource !== contactCache) {
|
||||
contactById = new Map(contactCache.map((c) => [c.id, c]));
|
||||
contactByIdSource = contactCache;
|
||||
}
|
||||
return contactById;
|
||||
}
|
||||
|
||||
/** Resolve a subject id to a display label using the preloaded caches. */
|
||||
export function resolveLabel(type: 'user' | 'group', id: string): string {
|
||||
if (type === 'group') return groupCache?.get(id) ?? id;
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
const c = contactIndex()?.get(id);
|
||||
return c ? contactLabel(c).label : id;
|
||||
}
|
||||
|
||||
@@ -146,7 +162,7 @@ export function resolveRecipient(type: 'user' | 'group', id: string): Recipient
|
||||
if (type === 'group') {
|
||||
return { type: 'group', id, label: groupCache?.get(id) ?? id };
|
||||
}
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
const c = contactIndex()?.get(id);
|
||||
if (!c) return { type: 'user', id, label: id };
|
||||
const { label, email } = contactLabel(c);
|
||||
return { type: 'user', id, label, sublabel: email };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Round-12 §F1 — list-view thumbnail rendition (benches/ROUND12.md).
|
||||
//
|
||||
// The list rows draw file thumbnails in a 40×40 CSS-px slot (100×70 in
|
||||
// grid), but both views requested the 400px `preview` rendition. The list
|
||||
// view now requests the 150px `icon` rendition: still ≥2× device-pixel
|
||||
// density for the 40px slot, at ~1/7th of the decoded pixels (and roughly
|
||||
// icon ≈ 4-8 KB vs preview ≈ 20-40 KB encoded WebP per thumbnail).
|
||||
//
|
||||
// Gates: the URL actually switches per view; grid keeps `preview`; the
|
||||
// pixel-area saving is the documented ~7x.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fileThumbnailUrl, thumbSizeForView } from './files';
|
||||
|
||||
describe('round12 §F1 — thumbnail rendition per view', () => {
|
||||
it('list view requests the icon rendition, grid keeps preview', () => {
|
||||
expect(thumbSizeForView('list')).toBe('icon');
|
||||
expect(thumbSizeForView('grid')).toBe('preview');
|
||||
expect(fileThumbnailUrl('abc', thumbSizeForView('list'))).toBe('/api/files/abc/thumbnail/icon');
|
||||
expect(fileThumbnailUrl('abc', thumbSizeForView('grid'))).toBe(
|
||||
'/api/files/abc/thumbnail/preview'
|
||||
);
|
||||
});
|
||||
|
||||
it('icon rendition moves ~7x fewer pixels than preview for the 40px slot', () => {
|
||||
// Server renditions: icon = 150px, preview = 400px (see the photos
|
||||
// srcset: `icon 150w, preview 400w, large 800w`).
|
||||
const areaRatio = (400 * 400) / (150 * 150);
|
||||
expect(areaRatio).toBeGreaterThan(7);
|
||||
// The 40×40 slot at 2x DPR needs 80px — icon's 150px still
|
||||
// oversamples it; preview was pure waste.
|
||||
expect(150).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: SortBy;
|
||||
/** Abort the request when a newer search supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
|
||||
@@ -38,7 +40,10 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
params.append('limit', String(opts.limit ?? 100));
|
||||
params.append('offset', String(opts.offset ?? 0));
|
||||
params.append('sort_by', opts.sortBy ?? 'relevance');
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, { credentials: 'same-origin' });
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
}
|
||||
|
||||
/** A single autocomplete suggestion returned by the lightweight suggest endpoint. */
|
||||
@@ -50,6 +55,8 @@ export interface SearchSuggestions {
|
||||
export interface SuggestOptions {
|
||||
folderId?: string;
|
||||
limit?: number;
|
||||
/** Abort the request when a newer keystroke supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,13 +72,19 @@ export function searchSuggest(
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
if (opts.limit != null) params.append('limit', String(opts.limit));
|
||||
return apiJson<SearchSuggestions>(`/api/search/suggest?${params.toString()}`, {
|
||||
credentials: 'same-origin'
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
|
||||
/**
|
||||
* Clear the shared server-side search cache
|
||||
* (`DELETE /api/admin/search/cache`). Admin-only — moved from
|
||||
* `/api/search/cache` on 2026-07-17 because the underlying
|
||||
* `invalidate_all()` touches every tenant (see AuthZ audit #14).
|
||||
*/
|
||||
export async function clearSearchCache(): Promise<void> {
|
||||
const res = await apiFetch('/api/search/cache', {
|
||||
const res = await apiFetch('/api/admin/search/cache', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
@@ -111,3 +111,19 @@ export async function emptyTrash(): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /api/trash/drive/{drive_id}` — empty the trash within a
|
||||
* single drive. Used by the trash page's Drive group-by, where each
|
||||
* bucket header carries a per-drive Empty button so multi-drive
|
||||
* owners don't have to wipe everything at once. Refused 404 when the
|
||||
* caller lacks Delete on the named drive (anti-enum).
|
||||
*/
|
||||
export async function emptyTrashForDrive(driveId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/trash/drive/${encodeURIComponent(driveId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`empty drive trash failed: ${res.status}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user