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:
Bradley Nelson
2026-07-21 17:09:36 -06:00
600 changed files with 105575 additions and 14440 deletions
+16 -2
View File
@@ -142,12 +142,26 @@ export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit):
}
export class ApiError extends Error {
/**
* `error_type` field from the backend's `ErrorResponse` body, when
* present. Callers switch on this to render specific UX for
* distinguished failures (e.g. `EmailNotVerified` → "resend
* verification link" prompt). Falls back to `undefined` when the
* response body isn't parseable or the endpoint doesn't emit one.
*/
readonly errorType?: string;
constructor(
readonly status: number,
readonly statusText: string,
readonly resource: RequestInfo | URL
readonly resource: RequestInfo | URL,
errorType?: string,
serverMessage?: string
) {
super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`);
super(
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
);
this.name = 'ApiError';
this.errorType = errorType;
}
}
+129
View File
@@ -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 {
+1 -1
View File
@@ -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;
+92 -7
View File
@@ -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.
});
});
+60 -1
View File
@@ -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
}
+63
View File
@@ -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
+10
View File
@@ -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' });
});
});
+110 -31
View File
@@ -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: [] } };
}
+10
View File
@@ -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> {
+17
View File
@@ -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);
});
});
+18 -2
View File
@@ -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);
});
});
+17 -4
View File
@@ -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'
});
+16
View File
@@ -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}`);
}
+92 -3
View File
@@ -24,10 +24,30 @@ export interface FolderItem {
is_root: boolean;
modified_at: number;
name: string;
owner_id: string;
// §14 provenance — who originally created the folder. `null` when
// the creating user has since been deleted (backend FK is
// `ON DELETE SET NULL`), or when the folder is returned to a
// share recipient that lost provenance via
// `FolderDto::without_hierarchy_info`. The canonical "owner"
// signal on the Files browser / Favorites / Shared surfaces
// (replaced the retired `owner_id` field in D7).
created_by: string | null;
// §14 provenance — who last touched the folder (rename / move /
// metadata change). The canonical "who touched this recently"
// signal on the Recent surface.
updated_by: string | null;
parent_id: string | null;
path: string;
etag: string;
/**
* The drive this folder belongs to (post-D0 ownership pivot per
* `docs/plan/drive.md` §3). Populated by the backend `FolderDto`
* on every response; the field was left out of the TS type until
* a caller needed it. Used by `/files` to resolve the current
* drive for the read-only banner without depending on the URL's
* leading segment being a drive-root folder id.
*/
drive_id: string;
}
export interface FileItem {
@@ -39,7 +59,10 @@ export interface FileItem {
mime_type: string;
modified_at: number;
name: string;
owner_id: string;
// §14 provenance — see FolderItem for semantics. Replaced the
// retired `owner_id` field in D7.
created_by: string | null;
updated_by: string | null;
folder_id: string;
path: string;
size: number;
@@ -96,7 +119,6 @@ export interface FavoriteItem {
icon_special_class: string;
category: string;
size_formatted: string;
owner_id: string | null;
}
export interface RecentItem {
@@ -159,6 +181,22 @@ export interface User {
email_verified_at?: string;
preferred_locale?: string;
notify_on_share: boolean;
/**
* Opaque UI preferences bag. Server-side JSONB column that persists
* pure UI toggles (hide-dotfiles, view mode, sidebar collapse, …)
* across devices. The server never inspects the contents — the SPA
* defines the keys (see `lib/stores/preferences.svelte.ts` for the
* typed view). Always an object on the wire (empty bag is `{}`,
* never `null` or missing).
*
* When PATCHing back to the server via
* `PATCH /api/auth/me/profile { ui_preferences: {...} }`, the
* server SHALLOW-merges — only the keys present in the patch are
* touched, so partial writes from one device don't clobber
* preferences set on another. Set a key to `null` in the patch to
* delete it from the bag.
*/
ui_preferences: Record<string, unknown>;
}
export interface AuthResponse {
@@ -237,12 +275,63 @@ export interface Drive {
root_folder_id: string;
quota_bytes?: number | null;
used_bytes: number;
/**
* Drive policies — raw JSONB bag from the backend. Unknown keys are
* preserved verbatim. For the typed view used by the admin policy
* editor, see [`DrivePolicies`].
*/
policies: Record<string, unknown>;
created_at: string;
updated_at: string;
caller_role?: DriveRole | null;
}
/**
* Typed mirror of the known drive policy keys. Every field defaults to
* `false` (= "opted out" for the `include_in_*` keys, "allowed" for the
* `forbid_*` keys). The wire shape returned by
* `PATCH /api/drives/{id}/policies` carries every known key; the request
* body uses [`DrivePoliciesPartial`] so unsupplied keys aren't disturbed
* (the backend uses a JSONB `||` merge — see
* `drive_pg_repository.rs::update_policies`).
*
* See `docs/plan/drive.md` §8 for the `forbid_*` gates and §15 for the
* `include_in_*_index` scope flags.
*/
export interface DrivePolicies {
forbid_sharing: boolean;
forbid_external_sharing: boolean;
forbid_public_links: boolean;
forbid_cross_drive_move: boolean;
forbid_owner_role_change: boolean;
/**
* §15 opt-in for `/api/photos` timeline scope. Default personal drives
* are created with `true`; non-default drives (secondary personals,
* shared) start `false` and opt in via the admin policy modal.
*/
include_in_photo_index: boolean;
/**
* §15 opt-in for the Music library surface (currently playlists;
* future `/api/music/tracks` library view will read this too).
* Symmetric shape to `include_in_photo_index`.
*/
include_in_music_index: boolean;
/**
* Full freeze / legal-hold. When `true`, every mutation on resources
* in the drive is refused — user-initiated AND background alike (the
* trash-retention purge SQL filter excludes read-only drives). Only
* `Read` passes. Admins can un-freeze via the admin-only policy PATCH.
* See `docs/plan/drive.md` §8 (`read_only`).
*/
read_only: boolean;
}
/**
* Body shape for the admin policy editor — every key optional so omitting
* a field leaves that policy untouched (the backend uses a JSONB merge).
*/
export type DrivePoliciesPartial = Partial<DrivePolicies>;
/**
* Request body for `POST /api/drives` (D3a). Mirrors `CreateDriveDto` in
* `src/interfaces/api/handlers/drive_handler.rs`. `kind: 'personal'` is a