/** * useFileApi — backend wrapper for the manager + ancillary endpoints. * * Two URL strategies, picked at construction time: * * 1. NEW (RESTful): caller passes `apiBase: 'https://files.example.com'`. * The manager endpoint becomes `${apiBase}/api/files/manager` and * every action uses `?action=…&path=…` as a query parameter (still * Vuefinder-compatible — the same `?q=…` convention is accepted by * the new Go backend, but `action` is the canonical name in v0.1+). * * 2. LEGACY (Vuefinder-compat): caller passes `endpoint: * '/api/files/manager'` (and the rest of the per-route fields). * Used by `@brftech/file-explorer` 0.1.0 embedders that have a * Laravel/Filament backend already. * * Per-route fields (`uploadInit`, `shareCreate`, …) ALWAYS win over * the auto-derived `apiBase` URL — lets the caller mix and match. * * Auth normalisation is centralised here. The component code never * thinks about CSRF vs Bearer vs Basic — it just calls `index(path)`. */ import type { ExplorerConfig, AuthConfig, EndpointMap } from '../types/ExplorerConfig'; import { resolveLocale } from '../locales/resolve'; import type { FileNode, ShareInfo, UploadLimits, Capabilities, ArchiveEntry, TrashEntry, } from '../types/FileNode'; /** Server-side PendingOp DTO (mirror of Modules\FishApp\Models\PendingOp::toApiArray). */ export interface PendingOpDto { id: number; op_type: 'copy' | 'move' | 'delete'; status: 'pending' | 'running' | 'done' | 'error'; progress_total: number; progress_done: number; target_path: string | null; source_dir: string | null; source_count: number; error_message: string | null; started_at: string | null; finished_at: string | null; created_at: string | null; } /** Answer to `?action=newfile` — where the new document actually landed. */ export interface NewFileResponse { /** Adapter-qualified path, ready to hand to the viewer. */ path: string; /** Final basename, which may have gained the extension server-side. */ name: string; ext: string; size: number; mime: string; } export interface ManagerResponse { adapter: string; storages: string[]; dirname: string; read_only: boolean; /** RBAC effective level for the current user on this directory ('' when ACL * is not enforced on the storage). Gates the folder-level write actions. */ perm?: 'none' | 'viewer' | 'editor' | 'owner'; /* wiring:e2 — E2E-encrypted folder awareness: `e2e` is true when the listed * dir IS an encrypted root; `e2e_root` is the adapter-qualified path of the * nearest encrypted root covering this dir (set for the root itself AND for * every subfolder inside the subtree). Absent on plain folders / old * backends — consumers must stay undefined-safe. */ e2e?: boolean; e2e_root?: string; files: FileNode[]; } /** A single ACL grant row (RBAC permissions panel). */ export interface Grant { id: number; storage_id: number; path_prefix: string; user_id: number; level: 'viewer' | 'editor' | 'owner'; user_email?: string; user_display_name?: string; inherited?: boolean; } /** surucu:d1 — `GET /api/files/quota/me` (quota.Snapshot). */ export interface QuotaSnapshot { used_bytes: number; quota_bytes: number; percent_used: number; unlimited: boolean; } export interface PermissionsResponse { path: string; storage_rbac: boolean; direct: Grant[]; inherited: Grant[]; effective: string; } export interface ResolveEmailResponse { found: boolean; user?: { id: number; email: string; display_name: string; role: string }; } export interface UserSuggestion { id: number; email: string; display_name: string; role: string; } /* === koru:k1 — version history (inspector panel) === */ /** Mirrors backend `model.NodeVersion` (GET /api/files/versions?node_id=…). */ export interface NodeVersion { id: number; node_id: number; version_n: number; storage_key?: string; size: number; etag?: string; created_at: string; } export interface UserSearchResponse { users: UserSuggestion[]; } /* === calisma:d3 — node comments (inspector panel) === */ /** Mirrors backend `model.NodeComment` (GET /api/files/comments?node_id=…). */ export interface NodeComment { id: number; node_id: number; user_id: number; body: string; created_at: string; updated_at?: string; /** Joined author display name (email fallback), filled by the backend. */ author_name?: string; /** Whether the CURRENT caller may delete this row (author or admin). */ can_delete?: boolean; } /* === /calisma:d3 === */ export interface InviteResponse { mode: 'granted' | 'user_created' | 'shared'; user_id?: number; url?: string; temp_password?: string; emailed: boolean; } /* === bul:s3 — global search (GET /api/files/search) === */ export type GlobalSearchScope = 'name' | 'content' | 'all'; /** * One hit from the dedicated files-search endpoint. The backend returns raw * node rows (`{results: [...]}`), so `path` is the IN-STORAGE relative path * (no `adapter://` prefix) and the storage comes back as a numeric * `storage_id`. `snippet`/`matched` are the v0.2 "Bul" contract additions — * older backends simply omit them, so every consumer must stay * undefined-safe. */ export interface GlobalSearchHit { id?: number; storage_id?: number; name?: string; path?: string; /** `file` | `dir` (backend NodeType). */ type?: string; size?: number; mime?: string; /** Plain-text content snippet; matches wrapped in «» (never HTML). */ snippet?: string; /** Where the hit matched: name | content | both. */ matched?: 'name' | 'content' | 'both'; [k: string]: unknown; } /** * Resolve a Vuefinder-compatible endpoint map from the user's config. * Either `apiBase` is set (auto-derive everything) or each route is * supplied explicitly (legacy). Mixed mode works too — explicit fields * trump the derived URL. */ export function resolveEndpoints(config: ExplorerConfig): EndpointMap { // `apiBase: ''` (empty string) is a *valid* relative-root prefix — // it produces URLs like `/api/files/copy`. Treat only `undefined` // as "no apiBase, legacy explicit-only mode". Falsy boolean checks // would silently drop relative-root callers and leave every derived // endpoint null → "endpoint not configured" UI dead-ends. const base = config.apiBase != null ? config.apiBase.replace(/\/+$/, '') : null; function derive(path: string | undefined, autoSegment: string): string | null { if (path) return path; if (base === null) return null; return `${base}${autoSegment}`; } // Manager URL is mandatory — pick the explicit `endpoint` first, then // fall back to `${apiBase}/api/files/manager`. const manager = config.endpoint ?? (base !== null ? `${base}/api/files/manager` : null); if (!manager) { throw new Error( "[@brftech/filex-core] config requires either `apiBase` or `endpoint`", ); } return { manager, // Staged (chunked + resumable) uploads — what useUploadChunked speaks on // every driver. The {id} routes are derived from this one. uploadBegin: derive(config.uploadBegin, '/api/files/upload/begin'), uploadInit: derive(config.uploadInit, '/api/files/upload/init'), uploadFinalize: derive(config.uploadFinalize, '/api/files/upload/finalize'), uploadAbort: derive(config.uploadAbort, '/api/files/upload/abort'), shareCreate: derive(config.shareCreate, '/api/files/share'), shareList: derive(config.shareList, '/api/files/share'), shareDelete: derive(config.shareDelete, '/api/files/share/{uuid}'), limits: derive(config.limits, '/api/files/limits'), capabilities: derive(config.capabilities, '/api/files/capabilities'), archiveList: derive(config.archiveList, '/api/files/archive/list'), archiveExtract: derive(config.archiveExtract, '/api/files/archive/extract'), archiveAdd: derive(config.archiveAdd, '/api/files/archive/add'), copy: derive(config.copy, '/api/files/copy'), moveAsync: derive(config.moveAsync, '/api/files/move'), deleteAsync: derive(config.deleteAsync, '/api/files/delete'), opsList: derive(config.opsList, '/api/files/ops'), opsShow: derive(config.opsShow, '/api/files/ops/{id}'), onlyOfficeConfig: derive(config.onlyOfficeConfig, '/api/files/onlyoffice/config'), saveText: derive(config.saveText, '/api/files/save-text'), restore: derive(config.restore, '/api/files/restore'), // filex trash: list soft-deleted nodes + restore one by node id. trashList: derive(config.trashList, '/api/files/manager/trash'), trashRestore: derive(config.trashRestore, '/api/files/manager/restore'), /* wiring:e2 — escrow proof-of-possession, then the owner is told. */ e2eEscrowChallenge: derive(config.e2eEscrowChallenge, '/api/files/e2e/escrow/challenge'), e2eEscrowUsed: derive(config.e2eEscrowUsed, '/api/files/e2e/escrow/used'), }; } /** * Resolve a possibly-async bearer token to a string. Caller passes * `auth.token` here so the auth header is fresh on every request. */ async function resolveToken(t: string | (() => string | Promise)): Promise { if (typeof t === 'function') { const out = t(); return out instanceof Promise ? await out : out; } return t; } /** * Normalise the legacy `{type: 'bearer'}` shape to the modern * `{kind: 'bearer'}` discriminator. Lets us write a single auth-header * builder downstream. */ function normalizeAuth(auth: AuthConfig | undefined): { kind: 'bearer'; token: string | (() => string | Promise) } | { kind: 'csrf'; csrf: string } | { kind: 'basic'; user: string; pass: string } | { kind: 'none' } { if (!auth) return { kind: 'none' }; if ('kind' in auth) return auth; // Legacy shape — translate. if (auth.type === 'bearer') return { kind: 'bearer', token: auth.token }; if (auth.type === 'csrf') return { kind: 'csrf', csrf: auth.csrf }; return { kind: 'none' }; } export function useFileApi(config: ExplorerConfig) { const endpoints = resolveEndpoints(config); const authConf = normalizeAuth(config.auth); /** * Last bearer value a function-token actually produced. * * ⚠ Exists for `authHeadersSync` only. A function token is resolved * asynchronously (the desktop app fetches it from the main process per * call), and a synchronous caller cannot wait for that — before this cache * it simply emitted NO Authorization header, so the request went out * anonymous and came back 401. Remembering the last value turns "no * credential at all" into "the credential we last held", which is the * difference between a dead feature and a stale-token retry. */ let lastBearer: string | null = null; async function authHeaders(extra: Record = {}): Promise> { const h: Record = { Accept: 'application/json', ...extra }; if (authConf.kind === 'bearer') { const token = await resolveToken(authConf.token); if (token) { h.Authorization = `Bearer ${token}`; lastBearer = token; } } else if (authConf.kind === 'csrf') { h['X-CSRF-TOKEN'] = authConf.csrf; h['X-Requested-With'] = 'XMLHttpRequest'; } else if (authConf.kind === 'basic') { const creds = btoa(`${authConf.user}:${authConf.pass}`); h.Authorization = `Basic ${creds}`; } return h; } /** * Sync auth-header builder for the few callers that genuinely cannot await * (XMLHttpRequest's `setRequestHeader` loop). * * ⚠ Prefer `authHeaders()`. A function token can only be *resolved* * asynchronously, so this returns the last value one produced — which is * nothing at all until the first async call has run. Measured on 2026-08-10 * in the desktop app: the OnlyOffice config POST, the starred list and the * recently-opened POST all went out with no Authorization header and came * back 401, because every one of them reached the API through this function. */ function authHeadersSync(extra: Record = {}): Record { const h: Record = { Accept: 'application/json', ...extra }; if (authConf.kind === 'bearer') { const token = typeof authConf.token === 'string' ? authConf.token : lastBearer; if (token) h.Authorization = `Bearer ${token}`; } else if (authConf.kind === 'csrf') { h['X-CSRF-TOKEN'] = authConf.csrf; h['X-Requested-With'] = 'XMLHttpRequest'; } else if (authConf.kind === 'basic') { const creds = btoa(`${authConf.user}:${authConf.pass}`); h.Authorization = `Basic ${creds}`; } return h; } function credentialsMode(): RequestCredentials { return authConf.kind === 'csrf' ? 'include' : 'same-origin'; } // Map an HTTP status to a short, human-readable message in the explorer's // locale. The raw JSON body is attached as `.detail` for debugging but never // shown in the toast (Ada, translated from Turkish: "when it gives a 404/403 // or whatever, I see raw json"). function statusMessage(status: number): string { const tr = resolveLocale(config.locale) !== 'en'; const m: Record = { 400: ['Geçersiz istek', 'Bad request'], 401: ['Oturum gerekli, tekrar giriş yapın', 'Sign-in required'], 403: ['Bu işlem için yetkiniz yok', 'You are not allowed to do this'], 404: ['Bulunamadı', 'Not found'], 409: ['Zaten var / çakışma', 'Already exists / conflict'], 413: ['Dosya çok büyük', 'File too large'], 415: ['Bu dosya türü desteklenmiyor', 'Unsupported file type'], 422: ['Geçersiz veri', 'Invalid data'], 429: ['Çok fazla istek, biraz bekleyin', 'Too many requests'], 500: ['Sunucu hatası', 'Server error'], 501: ['Bu işlem desteklenmiyor', 'Not supported'], 503: ['Servis şu an kullanılamıyor', 'Service unavailable'], }; const e = m[status]; if (e) return tr ? e[0] : e[1]; return tr ? `Hata (${status})` : `Error (${status})`; } async function jsonFetch(url: string, init: RequestInit = {}): Promise { const headers = { ...(await authHeaders()), ...((init.headers as Record | undefined) ?? {}), }; const res = await fetch(url, { ...init, headers, credentials: credentialsMode(), }); if (!res.ok) { const text = await res.text().catch(() => ''); const err = new Error(statusMessage(res.status)) as Error & { status?: number; detail?: string }; err.status = res.status; err.detail = text.slice(0, 300); throw err; } // ⚠⚠ A 204 carries NO BODY, and several endpoints answer with one (every // delete does). Parsing it throws "Unexpected end of JSON input" AFTER the // server has already done the work, so the caller reports a failure for an // operation that succeeded — measured 2026-08-16 in a browser: revoking an // S3 access key deleted it on the server and left it on screen with an // error under it, which invites the user to trust a credential that is // gone. An empty success is a success. if (res.status === 204 || res.status === 205) return undefined as T; const body = await res.text(); if (!body) return undefined as T; return JSON.parse(body) as T; } // -------------------------------------------------------------------- // Permissions (RBAC) — derived from the manager endpoint by swapping the // trailing `/manager` for `/permissions`. Owner/admin only (backend gated). // -------------------------------------------------------------------- function permissionsUrl(sub = ''): string { const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/permissions'); return base + sub; } async function listPermissions(path: string): Promise { return jsonFetch(permissionsUrl() + '?path=' + encodeURIComponent(path)); } async function resolveEmail(email: string): Promise { return jsonFetch(permissionsUrl('/resolve') + '?email=' + encodeURIComponent(email)); } async function searchUsers(q: string): Promise { return jsonFetch(permissionsUrl('/users') + '?q=' + encodeURIComponent(q)); } async function addPermission(body: { path: string; user_id: number; level: string; is_dir?: boolean }): Promise { return jsonFetch(permissionsUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } async function updatePermission(id: number, level: string): Promise { return jsonFetch(permissionsUrl('/' + id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level }) }); } async function deletePermission(id: number): Promise { return jsonFetch(permissionsUrl('/' + id), { method: 'DELETE' }); } async function invitePermission(body: { path: string; email: string; level: string; create_user?: boolean; role?: string; is_dir?: boolean; locale?: string }): Promise { return jsonFetch(permissionsUrl('/invite'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } async function shareMail(body: { path: string; email?: string; emails?: string[]; url: string; pin?: string | null; expires_days?: number; locale?: string; is_dir?: boolean; size?: number; mode?: string }): Promise<{ emailed: boolean; sent?: string[]; failed?: string[] }> { return jsonFetch<{ emailed: boolean; sent?: string[]; failed?: string[] }>(permissionsUrl('/share-mail'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } // -------------------------------------------------------------------- // Manager contract — supports both `?q=action` (legacy) and // `?action=action` (new) by emitting BOTH. Backends recognising one // ignore the other; new backends prefer `action`. // -------------------------------------------------------------------- function qs(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null) continue; sp.set(k, String(v)); } return sp.toString(); } function managerUrl(action: string, params: Record = {}): string { const sep = endpoints.manager.includes('?') ? '&' : '?'; return `${endpoints.manager}${sep}${qs({ q: action, action, ...params })}`; } async function index(path: string): Promise { return jsonFetch(managerUrl('index', { path })); } async function search(path: string, filter: string): Promise { return jsonFetch(managerUrl('search', { path, filter })); } /* === bul:s3 — global "search everywhere" === * Derived from the manager endpoint by swapping `/manager` for `/search` * (same trick permissionsUrl uses), so embedded proxies that forward the * whole /api/files/* subtree keep working. Errors and legacy backends * degrade to an empty result list — the palette just shows nothing. */ async function globalSearch( query: string, opts: { limit?: number; scope?: GlobalSearchScope } = {}, ): Promise { const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/search'); const sep = base.includes('?') ? '&' : '?'; const url = `${base}${sep}${qs({ q: query, limit: opts.limit, scope: opts.scope })}`; const data = await jsonFetch<{ results?: GlobalSearchHit[] | null }>(url); return Array.isArray(data?.results) ? data.results : []; } /* === surucu:d1 — the signed-in person's storage line ================== * `GET /api/files/quota/me` → `{used_bytes, quota_bytes, percent_used, * unlimited}` (internal/quota/service.go `Snapshot`). Derived from the * manager endpoint the same way permissions and search are, so a proxy that * forwards /api/files/* keeps working. * * ⚠ PER-USER, never per-storage: usage is `SUM(nodes.size) WHERE owner_id=me` * and there is no per-provider quota. Anything labelling this figure with a * drive's name is describing a number the server did not send. * * ⚠ Returns null rather than throwing on ANY failure — a server without the * route (404), an app token with no person behind it (403) or an older build * must leave the panel exactly as it was, not put an error where a status * line goes. */ async function quotaMe(): Promise { const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/quota/me'); try { const q = await jsonFetch(base); return q && typeof q.used_bytes === 'number' ? q : null; } catch { return null; } } async function subfolders(path: string): Promise<{ folders: FileNode[] }> { return jsonFetch<{ folders: FileNode[] }>(managerUrl('subfolders', { path })); } async function newFolder(path: string, name: string): Promise { return jsonFetch(managerUrl('newfolder'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, name }), }); } /** * Create an empty document of a known type in `path`. * * `type` is an extension from `capabilities.newdoc_types` — the registry the * SERVER compiled in, not a list the client keeps. That matters for the * office formats: a .docx is a ZIP of XML parts, so "create an empty file" * has to be answered by whoever holds the template bytes, and the client * cannot manufacture one. * * `name` may or may not already carry the extension; the server appends it * when it is missing. Throws on a name collision (409 NAME_TAKEN) — the * dialog warns first, but this is the check. */ async function newFile(path: string, name: string, type: string): Promise { return jsonFetch(managerUrl('newfile'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, name, type }), }); } async function rename(path: string, item: string, name: string): Promise { return jsonFetch(managerUrl('rename'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, item, name }), }); } async function move(path: string, items: string[], target: string): Promise { return jsonFetch(managerUrl('move'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, item: target, items: items.map((p) => ({ path: p })) }), }); } async function deleteItems(path: string, items: string[]): Promise { return jsonFetch(managerUrl('delete'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, items: items.map((p) => ({ path: p })) }), }); } /** Server-side recursive copy (async — returns a PendingOp). */ async function copy(source: string[], target: string): Promise<{ op: PendingOpDto }> { if (!endpoints.copy) throw new Error('copy endpoint not configured'); return jsonFetch(endpoints.copy, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, target }), }); } async function moveAsync(source: string[], target: string, sourceDir?: string): Promise<{ op: PendingOpDto }> { if (!endpoints.moveAsync) throw new Error('moveAsync endpoint not configured'); return jsonFetch(endpoints.moveAsync, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, target, sourceDir }), }); } async function deleteAsync(source: string[], sourceDir?: string): Promise<{ op: PendingOpDto }> { if (!endpoints.deleteAsync) throw new Error('deleteAsync endpoint not configured'); return jsonFetch(endpoints.deleteAsync, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, sourceDir }), }); } async function restore(source: string[]): Promise<{ ok: boolean; restored: number }> { if (!endpoints.restore) throw new Error('restore endpoint not configured'); return jsonFetch(endpoints.restore, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source }), }); } /** filex trash listing — soft-deleted nodes across (or within) storages. */ async function listTrash(storageName?: string): Promise<{ entries: TrashEntry[]; total: number }> { if (!endpoints.trashList) throw new Error('trashList endpoint not configured'); const base = endpoints.trashList; const sep = base.includes('?') ? '&' : '?'; const url = storageName ? `${base}${sep}storage=${encodeURIComponent(storageName)}` : base; return jsonFetch<{ entries: TrashEntry[]; total: number }>(url); } /** * Restore soft-deleted nodes by their node id. The filex backend restores * one node per call (`POST {node_id}`), so we fan out and tally successes. * * `taken` names the entries the server refused because something already * holds their original path (409 `EXISTS`). That refusal is the server * protecting the file that holds the name — a restore used to overwrite it — * so it is reported by name rather than folded into "0 items restored", * which would read as if nothing had been tried. */ async function restoreIds(ids: number[]): Promise<{ restored: number; taken: string[] }> { const url = endpoints.trashRestore; if (!url) throw new Error('trashRestore endpoint not configured'); let restored = 0; const taken: string[] = []; for (const id of ids) { try { await jsonFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: id }), }); restored++; } catch (err) { const e = err as { status?: number; detail?: string }; if (e.status === 409) { try { const body = JSON.parse(e.detail ?? '') as { code?: string; name?: string }; if (body.code === 'EXISTS') taken.push(body.name || String(id)); } catch { /* a 409 without the envelope is counted as a plain failure */ } } /* any other failure: skip it, report the count that succeeded */ } } return { restored, taken }; } /** * Legacy in-band multipart upload (small files / chunked endpoint * absent). XMLHttpRequest because fetch doesn't expose upload * progress on most browsers. */ async function uploadMultipart( path: string, files: File[], onProgress?: (p: number) => void, ): Promise { const fd = new FormData(); fd.append('path', path); for (const f of files) { fd.append('file[]', f, f.name); } const headers = await authHeaders(); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', managerUrl('upload')); for (const [k, v] of Object.entries(headers)) { if (k === 'Content-Type') continue; xhr.setRequestHeader(k, v); } xhr.withCredentials = credentialsMode() === 'include'; xhr.upload.onprogress = (ev) => { if (onProgress && ev.lengthComputable) { onProgress(Math.round((ev.loaded / ev.total) * 100)); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(e); } } else { reject(new Error(`${xhr.status} ${xhr.statusText}: ${xhr.responseText.slice(0, 200)}`)); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(fd); }); } function downloadUrl(path: string): string { return managerUrl('download', { path }); } function previewUrl(path: string): string { return managerUrl('preview', { path }); } /** * Fetch a file body with the configured auth headers + credentials, * returning the raw blob plus a normalized object URL viewers can mount * directly. Used by the rich viewers (3D, EPUB, PDF, PSD, TIFF, …) which * need an `ArrayBuffer` or a `Blob`-backed `objectURL` rather than the * relative preview URL. * * Caller is responsible for revoking `url` (`URL.revokeObjectURL(url)`) * once the viewer unmounts to avoid leaking the blob. */ async function fetchBlob( path: string, opts: { fresh?: boolean } = {}, ): Promise<{ url: string; blob: Blob; mime: string }> { const headers = await authHeaders(); const res = await fetch(previewUrl(path), { headers, credentials: credentialsMode(), // ⚠ The preview endpoint answers `Cache-Control: private, max-age=60`, // which is right for the thing it was built for — a viewer re-opening // an image — and wrong for anything the client treats as CONTROL data. // `.filex-e2e.json` is control data: after the client rewrites it // (recovery upgrade, escrow slot, escrow refusal) the next read inside // that minute came back PRE-write, so the folder looked un-upgraded // and the question filex had just been answered was asked again. // Measured 2026-09-05 in a real browser: decline the escrow offer, // reopen the folder, and the offer was back. cache: opts.fresh ? 'no-store' : 'default', }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `${res.status} ${res.statusText}${text ? ' — ' + text.slice(0, 200) : ''}`, ); } const blob = await res.blob(); const mime = blob.type || res.headers.get('content-type') || ''; const url = URL.createObjectURL(blob); return { url, blob, mime }; } /** * Like `fetchBlob` but returns the raw bytes — viewers that need * binary parsing (utif, ag-psd, pdfjs-dist) get the buffer directly * without the extra `Blob → arrayBuffer` round trip. */ async function fetchArrayBuffer(path: string): Promise { const headers = await authHeaders(); const res = await fetch(previewUrl(path), { headers, credentials: credentialsMode(), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `${res.status} ${res.statusText}${text ? ' — ' + text.slice(0, 200) : ''}`, ); } return res.arrayBuffer(); } // -------------------------------------------------------------------- // Peripheral endpoints // -------------------------------------------------------------------- async function limits(): Promise { if (!endpoints.limits) return { max_upload_mb: 1024 }; return jsonFetch(endpoints.limits); } async function capabilities(): Promise { if (!endpoints.capabilities) { return { ffmpeg: false, ghostscript: false, libreoffice: false, max_chunk_mb: 5, upload_limit_mb: 1024, onlyoffice_url: config.onlyOfficeBase ?? null, drawio_url: config.drawioBase ?? null, convert_url: config.convertBase ?? null, }; } return jsonFetch(endpoints.capabilities); } /* wiring:e2 — escrow use is announced, not merely performed. * * Ask the server for a nonce sealed to the escrow public key; only the * holder of the private half can read it back. Returning it is what earns * the notification to the folder's owner — a bare "I used escrow" POST * would be a string anyone could send. * * ⚠ This is an announcement, not a gate. An operator holding the private * key can decrypt the folder offline with a script and never come here. * docs/E2E-ENCRYPTION.md says so plainly and must keep saying so. */ async function e2eEscrowChallenge( path: string, ): Promise<{ id: string; challenge: string; kid: string }> { if (!endpoints.e2eEscrowChallenge) throw new Error('e2e escrow endpoint not configured'); return jsonFetch(endpoints.e2eEscrowChallenge, { method: 'POST', body: JSON.stringify({ path }), }); } async function e2eEscrowUsed(payload: { path: string; id: string; nonce: string; }): Promise<{ ok: boolean; notified: boolean }> { if (!endpoints.e2eEscrowUsed) throw new Error('e2e escrow endpoint not configured'); return jsonFetch(endpoints.e2eEscrowUsed, { method: 'POST', body: JSON.stringify(payload), }); } async function createShare(payload: { path: string; password?: boolean; expires_at?: string | null; max_downloads?: number | null; // File-drop (public upload link) — kind:'drop' mints an upload link into a // folder instead of a download link; drop_settings carries the caps. kind?: string; max_uploads?: number | null; drop_settings?: Record | null; }): Promise<{ share: ShareInfo & { url: string; path: string; filename: string; kind?: string } }> { if (!endpoints.shareCreate) throw new Error('shareCreate endpoint not configured'); return jsonFetch(endpoints.shareCreate, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); } async function listShares(path: string): Promise<{ shares: ShareInfo[] }> { if (!endpoints.shareList) return { shares: [] }; const sep = endpoints.shareList.includes('?') ? '&' : '?'; return jsonFetch(`${endpoints.shareList}${sep}path=${encodeURIComponent(path)}`); } async function revokeShare(uuid: string): Promise<{ success: boolean }> { if (!endpoints.shareDelete) throw new Error('shareDelete endpoint not configured'); const url = endpoints.shareDelete.replace('{uuid}', encodeURIComponent(uuid)); return jsonFetch(url, { method: 'DELETE' }); } async function archiveList(path: string): Promise<{ entries: ArchiveEntry[] }> { if (!endpoints.archiveList) throw new Error('archiveList endpoint not configured'); const raw = await jsonFetch<{ entries: Array<{ name: string; size: number; is_dir: boolean; mtime?: number }> }>( endpoints.archiveList, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }), }, ); return { entries: raw.entries.map((e) => ({ name: e.name, size: e.size, isDir: e.is_dir, lastModified: e.mtime, })), }; } async function archiveExtract(path: string, members?: string[]): Promise<{ keys: string[]; count: number }> { if (!endpoints.archiveExtract) throw new Error('archiveExtract endpoint not configured'); return jsonFetch(endpoints.archiveExtract, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, members }), }); } async function archiveAdd(path: string, files: Array<{ name: string; source: string }>): Promise<{ path: string }> { if (!endpoints.archiveAdd) throw new Error('archiveAdd endpoint not configured'); return jsonFetch(endpoints.archiveAdd, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, files }), }); } /* === koru:k1 — version history (inspector panel) === * Derived from the manager endpoint by swapping `/manager` for `/versions` * (same trick permissionsUrl/globalSearch use) so embedded proxies that * forward the whole /api/files/* subtree keep working. * GET /api/files/versions?node_id=N → {versions, node_id} * POST /api/files/versions/restore → {node_id, version_id, snapshot_current} * POST /api/files/versions/snapshot → {node_id} (may not exist on older backends) */ function versionsUrl(sub = ''): string { const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/versions'); return base + sub; } async function listVersions(nodeId: number): Promise { const data = await jsonFetch<{ versions?: NodeVersion[] | null }>( versionsUrl() + '?node_id=' + encodeURIComponent(String(nodeId)), ); return Array.isArray(data?.versions) ? data.versions : []; } async function restoreVersion( nodeId: number, versionId: number, snapshotCurrent = true, ): Promise { await jsonFetch(versionsUrl('/restore'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: nodeId, version_id: versionId, snapshot_current: snapshotCurrent, }), }); } async function snapshotVersion(nodeId: number): Promise { await jsonFetch(versionsUrl('/snapshot'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: nodeId }), }); } /* === /koru:k1 === */ /* === calisma:d3 — node comments (inspector panel) === * Same manager-URL derivation trick as versions/permissions so embedded * proxies forwarding the whole /api/files/* subtree keep working. * GET /api/files/comments?node_id=N → {comments, node_id} * POST /api/files/comments → {node_id, body} * DELETE /api/files/comments/{id} → {ok} */ function commentsUrl(sub = ''): string { const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/comments'); return base + sub; } async function listComments(nodeId: number): Promise { const data = await jsonFetch<{ comments?: NodeComment[] | null }>( commentsUrl() + '?node_id=' + encodeURIComponent(String(nodeId)), ); return Array.isArray(data?.comments) ? data.comments : []; } async function addComment(nodeId: number, body: string): Promise { const data = await jsonFetch<{ comment: NodeComment }>(commentsUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ node_id: nodeId, body }), }); return data.comment; } async function deleteComment(id: number): Promise { await jsonFetch(commentsUrl('/' + encodeURIComponent(String(id))), { method: 'DELETE', }); } /* === /calisma:d3 === */ // Mint a short-lived WebSocket auth ticket for the realtime layer. Derived // from the manager URL (so it flows through the same host proxy) and uses the // same auth/creds as every other call. Returns null on any failure (a backend // without the endpoint, a network error) so the caller falls back to polling. async function wsTicket(): Promise<{ ticket: string; ws_url: string } | null> { const url = endpoints.manager.replace(/\/manager(\?.*)?$/, '/ws-ticket'); try { return await jsonFetch<{ ticket: string; ws_url: string }>(url, { method: 'POST' }); } catch { return null; } } return { // Realtime wsTicket, // Manager index, search, globalSearch /* bul:s3 */, quotaMe /* surucu:d1 */, subfolders, newFolder, newFile, rename, move, copy, moveAsync, deleteAsync, deleteItems, restore, listTrash, restoreIds, uploadMultipart, downloadUrl, previewUrl, fetchBlob, fetchArrayBuffer, // Peripheral limits, capabilities, /* wiring:e2 */ e2eEscrowChallenge, e2eEscrowUsed, createShare, listShares, revokeShare, archiveList, archiveExtract, archiveAdd, // Version history (koru:k1 inspector) listVersions, restoreVersion, snapshotVersion, // Node comments (calisma:d3 inspector) listComments, addComment, deleteComment, // Permissions (RBAC panel) listPermissions, resolveEmail, searchUsers, addPermission, updatePermission, deletePermission, invitePermission, shareMail, // Internals (exposed for useUploadChunked + PreviewModal) endpoints, authHeaders, authHeadersSync, credentialsMode, jsonFetch, }; } export type FileApi = ReturnType;