init new frontend

This commit is contained in:
Bradley Nelson
2026-06-17 17:06:30 -06:00
parent b8a0018785
commit daa3010458
114 changed files with 32716 additions and 119 deletions
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiFetch } from './client';
const ORIGIN = 'https://cloud.example';
function jsonResponse(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), { status });
}
describe('createApiFetch — 401 refresh/retry parity', () => {
let onSessionExpired: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSessionExpired = vi.fn();
});
it('passes through a non-401 response untouched (no refresh)', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true }));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('on 401 refreshes once then retries the original request', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { retried: true })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ retried: true });
expect(rawFetch).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({ method: 'POST' })
);
expect(rawFetch).toHaveBeenCalledTimes(3);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('fires session-expired and throws when refresh fails', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(401)); // refresh fails
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
await expect(apiFetch(`${ORIGIN}/api/files`)).rejects.toThrow('Session expired');
expect(onSessionExpired).toHaveBeenCalledTimes(1);
expect(rawFetch).toHaveBeenCalledTimes(2); // original + refresh, NO retry
});
it('deduplicates concurrent 401s into a single refresh', async () => {
let refreshCalls = 0;
const rawFetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : (input as Request).url;
if (url.includes('/api/auth/refresh')) {
refreshCalls++;
await new Promise((r) => setTimeout(r, 10));
return jsonResponse(200);
}
// First hit per resource is a 401; retries (after refresh) succeed.
return jsonResponse(refreshCalls > 0 ? 200 : 401);
});
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const [a, b] = await Promise.all([
apiFetch(`${ORIGIN}/api/files`),
apiFetch(`${ORIGIN}/api/folders`)
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(refreshCalls).toBe(1); // single shared refresh
});
it('passes cross-origin 401s through without refreshing', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch('https://third-party.example/api/thing');
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1); // no refresh attempt
expect(onSessionExpired).not.toHaveBeenCalled();
});
it.each([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/start',
'/api/auth/device/code',
'/api/s/sometoken'
])('bypasses refresh for auth primitive / public share: %s', async (path) => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}${path}`);
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('retries user-data endpoints under /api/auth/ (e.g. me)', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original /api/auth/me
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { id: 'u1' })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/auth/me`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: 'u1' });
expect(rawFetch).toHaveBeenCalledTimes(3);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Typed API client with transparent 401 → token-refresh → retry.
*
* Ported from static/js/core/fetchWrapper.js. Unlike the legacy version this
* does NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
* explicitly. The behavioural invariants are preserved exactly:
*
* - A captured raw `fetch` is used for the real network calls so the refresh
* request and the retry never re-enter the interceptor (no recursion).
* - Concurrent 401s collapse into a single in-flight `/api/auth/refresh`.
* - Cross-origin responses are passed through untouched.
* - Auth primitives (login/logout/refresh/register/setup/oidc/device) and
* public-share endpoints (/api/s/) bypass the refresh-and-retry path:
* a 401 there is genuine ("bad credentials" / "password required"), not an
* expired access token.
* - When refresh fails, the session-expired handler fires (clear + redirect)
* and the call rejects.
*/
import { getCsrfHeaders } from './csrf';
const REFRESH_ENDPOINT = '/api/auth/refresh';
/** Auth primitives — a 401 here is genuine, never an expired access token. */
const AUTH_PRIMITIVES = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/',
'/api/auth/device/'
];
export type FetchFn = typeof fetch;
export interface ApiClientDeps {
/** Underlying fetch used for the real network call (bypasses the interceptor). */
rawFetch: FetchFn;
/** Invoked once when a refresh definitively fails (clear session + redirect). */
onSessionExpired: () => void;
/** Test seam for `window.location.origin`. */
origin?: string;
}
function urlString(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return input.url ?? '';
}
function isCrossOrigin(urlStr: string, origin: string): boolean {
try {
return new URL(urlStr, origin).origin !== origin;
} catch {
// Unparseable URL — treat as cross-origin so we pass it through untouched.
return true;
}
}
function bypassesRetry(urlStr: string): boolean {
return AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/');
}
/**
* Build an isolated apiFetch with its own refresh-dedup state. Used directly in
* tests; the app uses the default singleton below.
*/
export function createApiFetch(deps: ApiClientDeps): FetchFn {
const { rawFetch, onSessionExpired } = deps;
let refreshInFlight: Promise<boolean> | null = null;
async function refresh(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const r = await rawFetch(REFRESH_ENDPOINT, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
return r.ok;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
const apiFetch: FetchFn = async (input, init) => {
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
const response = await rawFetch(input, init);
if (response.status !== 401) return response;
const urlStr = urlString(input as RequestInfo | URL);
if (isCrossOrigin(urlStr, origin)) return response;
if (bypassesRetry(urlStr)) return response;
const refreshed = await refresh();
if (!refreshed) {
onSessionExpired();
throw new Error('Session expired');
}
return rawFetch(input, init);
};
return apiFetch;
}
// ── Default singleton ──────────────────────────────────────────────────────
let sessionExpiredHandler: () => void = () => {
if (typeof window !== 'undefined') {
window.location.href = '/login?source=session_expired';
}
};
/** Wire the real session-expired behaviour (clear store + redirect) at startup. */
export function setSessionExpiredHandler(fn: () => void): void {
sessionExpiredHandler = fn;
}
const rawFetch: FetchFn =
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
/** App-wide fetch — route every API call through this. */
export const apiFetch: FetchFn = createApiFetch({
rawFetch,
onSessionExpired: () => sessionExpiredHandler()
});
/** Convenience: fetch JSON, throwing on non-2xx. */
export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const res = await apiFetch(input, init);
if (!res.ok) {
throw new ApiError(res.status, res.statusText, input);
}
return (await res.json()) as T;
}
export class ApiError extends Error {
constructor(
readonly status: number,
readonly statusText: string,
readonly resource: RequestInfo | URL
) {
super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`);
this.name = 'ApiError';
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* CSRF double-submit cookie utility — ported from static/js/core/csrf.js.
*
* Reads the `oxicloud_csrf` cookie (NOT HttpOnly) and exposes its value as the
* `X-CSRF-Token` header. The server's `csrf_middleware` validates that the
* header matches the cookie for every mutating (POST/PUT/DELETE/PATCH) request
* authenticated via the HttpOnly session cookie.
*/
export function getCsrfToken(): string {
const match = document.cookie.split('; ').find((row) => row.startsWith('oxicloud_csrf='));
return match ? (match.split('=')[1] ?? '') : '';
}
/** Headers to merge into a mutating request; empty when no token is present. */
export function getCsrfHeaders(): Record<string, string> {
const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {};
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Admin endpoints — ported from views/admin/admin.js. Covers users + plugins
* (the core management surfaces). Settings (OIDC/storage/SMTP), storage
* migration, and plugin logs/retention are not yet ported — see the admin route.
*/
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
const res = await apiFetch(url, {
method,
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(e.message || `${method} ${url} failed: ${res.status}`);
}
}
// ── Users ───────────────────────────────────────────────────────────────
export interface AdminUsersPage {
total: number;
users: User[];
}
export function listUsers(limit: number, offset: number): Promise<AdminUsersPage> {
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}`, {
credentials: 'same-origin'
});
}
export interface CreateUserInput {
username: string;
password: string;
email: string;
role: string;
quota_bytes: number;
}
export function createUser(input: CreateUserInput): Promise<void> {
return mutate('/api/admin/users', 'POST', input);
}
export function setUserRole(userId: string, role: string): Promise<void> {
return mutate(`/api/admin/users/${userId}/role`, 'PUT', { role });
}
export function setUserActive(userId: string, active: boolean): Promise<void> {
return mutate(`/api/admin/users/${userId}/active`, 'PUT', { active });
}
export function setUserQuota(userId: string, quotaBytes: number): Promise<void> {
return mutate(`/api/admin/users/${userId}/quota`, 'PUT', { quota_bytes: quotaBytes });
}
export function resetUserPassword(userId: string, newPassword: string): Promise<void> {
return mutate(`/api/admin/users/${userId}/password`, 'PUT', { new_password: newPassword });
}
export function deleteUser(userId: string): Promise<void> {
return mutate(`/api/admin/users/${userId}`, 'DELETE');
}
// ── Plugins ─────────────────────────────────────────────────────────────
export interface PluginInfo {
id: string;
name: string;
version?: string;
enabled: boolean;
description?: string;
}
export interface PluginsResult {
/** false when the plugin subsystem is disabled (server returns 503). */
available: boolean;
enabled?: boolean;
plugins: PluginInfo[];
}
export async function listPlugins(): Promise<PluginsResult> {
const res = await apiFetch('/api/admin/plugins', { credentials: 'same-origin' });
if (res.status === 503) return { available: false, plugins: [] };
if (!res.ok) throw new Error(`plugins failed: ${res.status}`);
const data = (await res.json()) as { enabled?: boolean; plugins?: PluginInfo[] };
return { available: true, enabled: data.enabled, plugins: data.plugins ?? [] };
}
export function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/enabled`, 'PUT', { enabled });
}
export function deletePlugin(id: string): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}`, 'DELETE');
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Auth endpoints. The 401-refresh/dedup behaviour lives in apiFetch; the auth
* 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 { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
/**
* Probe the current session. Uses the raw `fetch` (NOT apiFetch) on purpose:
* a 401 here just means "not logged in" and must not trigger the global
* refresh-and-redirect (which would bounce the app in a refresh loop on the
* unauthenticated initial load). Returns null when unauthenticated.
*/
export async function fetchMe(): Promise<User | null> {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
return (await res.json()) as User;
}
/**
* Attempt a single token refresh (raw fetch, no interceptor). Returns whether
* it succeeded. Used by the startup probe; mid-session refresh is handled
* transparently by apiFetch for all other endpoints.
*/
export async function tryRefresh(): Promise<boolean> {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
return res.ok;
} catch {
return false;
}
}
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: emailOrUsername, password })
});
if (!res.ok) throw new Error(`login failed: ${res.status}`);
return (await res.json()) as AuthResponse;
}
export async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
}
+26
View File
@@ -0,0 +1,26 @@
/** Device-authorization (RFC 8628) verification endpoints. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
export interface DeviceInfo {
client_name?: string;
scopes?: string;
}
export async function lookupDeviceCode(code: string): Promise<DeviceInfo> {
const res = await apiFetch(`/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`device lookup failed: ${res.status}`);
return (await res.json()) as DeviceInfo;
}
export async function decideDevice(userCode: string, action: 'approve' | 'deny'): Promise<void> {
const res = await apiFetch('/api/auth/device/verify', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ user_code: userCode, action })
});
if (!res.ok) throw new Error(`device ${action} failed: ${res.status}`);
}
@@ -0,0 +1,41 @@
/** Favorites endpoints — ported from favoritesModel.js + features/library. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import {
fetchResourcePage,
type ResourceBody,
type ResourcePage,
type ResourcePageOpts
} from './resources';
import type { ItemType } from '$lib/api/types';
export interface FavoritesResourceItem {
resource_type: ItemType;
favorited_at: string;
resource: ResourceBody;
}
export function fetchFavoritesPage(
opts?: ResourcePageOpts
): Promise<ResourcePage<FavoritesResourceItem>> {
return fetchResourcePage<FavoritesResourceItem>('/api/favorites/resources', 'name', opts);
}
export async function addFavorite(type: ItemType, id: string): Promise<void> {
const res = await apiFetch(`/api/favorites/${type}/${id}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`add favorite failed: ${res.status}`);
}
export async function removeFavorite(type: ItemType, id: string): Promise<void> {
const res = await apiFetch(`/api/favorites/${type}/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`remove favorite failed: ${res.status}`);
}
+60
View File
@@ -0,0 +1,60 @@
/** File endpoints — ported from fileOperations.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export async function uploadFile(folderId: string | null, file: File): Promise<void> {
const form = new FormData();
if (folderId) form.append('folder_id', folderId);
form.append('file', file);
const res = await apiFetch('/api/files/upload', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: getCsrfHeaders(), // multipart boundary set automatically; do not set Content-Type
body: form
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
}
export async function renameFile(fileId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename file failed: ${res.status}`);
}
export async function moveFile(fileId: string, targetFolderId: string | null): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/move`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ folder_id: targetFolderId || null })
});
if (!res.ok) throw new Error(`move file failed: ${res.status}`);
}
export async function deleteFile(fileId: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete file failed: ${res.status}`);
}
export function fileDownloadUrl(fileId: string): string {
return `/api/files/${fileId}`;
}
export function fileInlineUrl(fileId: string): string {
return `/api/files/${fileId}?inline=true`;
}
export function fileThumbnailUrl(fileId: string): string {
return `/api/files/${fileId}/thumbnail/preview`;
}
+89
View File
@@ -0,0 +1,89 @@
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem, FolderItem } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const NO_CACHE: RequestInit = {
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }
};
export interface FolderListing {
folders: FolderItem[];
files: FileItem[];
}
/** Top-level folders for the user; the first entry is the home folder. */
export function listRootFolders(): Promise<FolderItem[]> {
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
}
export function getFolder(id: string): Promise<FolderItem> {
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
}
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const ts = Math.floor(Date.now() / 1000);
let url = `/api/folders/${folderId}/listing?t=${ts}`;
const headers: Record<string, string> = {
'Cache-Control': 'no-cache, no-store, must-revalidate'
};
if (forceRefresh) {
url += '&force_refresh=true';
headers['X-Force-Refresh'] = 'true';
}
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const listing = (await res.json()) as Partial<FolderListing>;
return {
folders: Array.isArray(listing.folders) ? listing.folders : [],
files: Array.isArray(listing.files) ? listing.files : []
};
}
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
const res = await apiFetch('/api/folders', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name, parent_id: parentId })
});
if (!res.ok) throw new Error(`create folder failed: ${res.status}`);
return (await res.json()) as FolderItem;
}
export async function renameFolder(folderId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename folder failed: ${res.status}`);
}
export async function moveFolder(folderId: string, targetFolderId: string | null): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}/move`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ parent_id: targetFolderId || null })
});
if (!res.ok) throw new Error(`move folder failed: ${res.status}`);
}
export async function deleteFolder(folderId: string): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete folder failed: ${res.status}`);
}
export function folderZipUrl(folderId: string): string {
return `/api/folders/${folderId}/download?format=zip`;
}
+59
View File
@@ -0,0 +1,59 @@
/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */
import { apiFetch } from '$lib/api/client';
import type { ItemType } from '$lib/api/types';
import type { ResourceBody, ResourcePage } from './resources';
export interface IncomingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
granted_by?: string;
granted_at?: string;
role?: string;
}
export interface OutgoingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
subject?: string;
first_shared_at?: string;
role?: string;
}
interface GrantsPageOpts {
cursor?: string;
orderBy?: string;
limit?: number;
reverse?: boolean;
resourceTypes?: ItemType[];
}
function params(opts: GrantsPageOpts): string {
const { cursor, orderBy, limit = 50, reverse = false, resourceTypes } = opts;
const p = new URLSearchParams({ limit: String(limit) });
if (resourceTypes?.length) p.set('resource_types', resourceTypes.join(','));
if (cursor) p.set('cursor', cursor);
if (orderBy) p.set('sort_by', orderBy);
if (reverse) p.set('reverse', 'true');
return p.toString();
}
export async function fetchSharedWithMe(
opts: GrantsPageOpts = {}
): Promise<ResourcePage<IncomingGrantItem>> {
const res = await apiFetch(
`/api/grants/incoming/resources?${params({ resourceTypes: ['file', 'folder'], ...opts })}`,
{ credentials: 'same-origin' }
);
if (!res.ok) throw new Error(`shared-with-me failed: ${res.status}`);
return (await res.json()) as ResourcePage<IncomingGrantItem>;
}
export async function fetchMyShares(
opts: GrantsPageOpts = {}
): Promise<ResourcePage<OutgoingGrantItem>> {
const res = await apiFetch(`/api/grants/outgoing/resources?${params(opts)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`my-shares failed: ${res.status}`);
return (await res.json()) as ResourcePage<OutgoingGrantItem>;
}
+70
View File
@@ -0,0 +1,70 @@
/** Group (ReBAC) endpoints — ported from model/groups.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const enc = encodeURIComponent;
export interface GroupItem {
id: string;
name: string;
description?: string | null;
member_count?: number;
}
export interface GroupMember {
user_id?: string;
group_id?: string;
email?: string;
name?: string;
}
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
const res = await apiFetch(url, {
method,
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) throw new Error(`${method} ${url} failed: ${res.status}`);
}
/** The list endpoint may return an array or `{ groups | items, total }`. */
export async function listGroups(limit = 50, offset = 0, q?: string): Promise<GroupItem[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
const data = await apiJson<GroupItem[] | { groups?: GroupItem[]; items?: GroupItem[] }>(
`/api/groups?${params}`,
{ credentials: 'same-origin' }
);
if (Array.isArray(data)) return data;
return data.groups ?? data.items ?? [];
}
export function createGroup(name: string, description?: string | null): Promise<void> {
return mutate('/api/groups', 'POST', { name, description: description ?? null });
}
export function renameGroup(id: string, name: string): Promise<void> {
return mutate(`/api/groups/${enc(id)}`, 'PATCH', { name });
}
export function deleteGroup(id: string): Promise<void> {
return mutate(`/api/groups/${enc(id)}`, 'DELETE');
}
export function listMembers(id: string): Promise<GroupMember[]> {
return apiJson<GroupMember[]>(`/api/groups/${enc(id)}/members`, { credentials: 'same-origin' });
}
export function addUserMember(groupId: string, userId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members`, 'POST', { user_id: userId });
}
export function removeUserMember(groupId: string, userId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members/user/${enc(userId)}`, 'DELETE');
}
export function removeGroupMember(groupId: string, memberGroupId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members/group/${enc(memberGroupId)}`, 'DELETE');
}
+103
View File
@@ -0,0 +1,103 @@
/** Music / playlist endpoints — ported from features/library/music.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export interface Playlist {
id: string;
name: string;
description: string | null;
owner_id: string;
is_public: boolean;
cover_file_id: string | null;
track_count: number;
total_duration_secs: number;
created_at: number;
updated_at: number;
}
export interface PlaylistItem {
id: string;
playlist_id: string;
file_id: string;
position: number;
added_at: number;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
title: string | null;
artist: string | null;
album: string | null;
duration_secs: number | null;
}
export function listPlaylists(): Promise<Playlist[]> {
return apiJson<Playlist[]>('/api/playlists', { credentials: 'same-origin' });
}
export function listTracks(playlistId: string): Promise<PlaylistItem[]> {
return apiJson<PlaylistItem[]>(`/api/playlists/${playlistId}/tracks`, {
credentials: 'same-origin'
});
}
export async function createPlaylist(name: string): Promise<Playlist> {
const res = await apiFetch('/api/playlists', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name, description: null })
});
if (!res.ok) throw new Error(`create playlist failed: ${res.status}`);
return (await res.json()) as Playlist;
}
export async function renamePlaylist(playlistId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename playlist failed: ${res.status}`);
}
export async function deletePlaylist(playlistId: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete playlist failed: ${res.status}`);
}
export async function addTracks(playlistId: string, fileIds: string[]): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ file_ids: fileIds })
});
if (!res.ok) throw new Error(`add tracks failed: ${res.status}`);
}
export async function removeTrack(playlistId: string, fileId: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/tracks/${encodeURIComponent(fileId)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`remove track failed: ${res.status}`);
}
/** Persist a new track order. `itemIds` are PlaylistItem ids in the desired order. */
export async function reorderTracks(playlistId: string, itemIds: string[]): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/reorder`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ item_ids: itemIds })
});
if (!res.ok) throw new Error(`reorder failed: ${res.status}`);
}
+26
View File
@@ -0,0 +1,26 @@
/** Photos timeline endpoint — ported from features/library/photos.js. */
import { apiFetch } from '$lib/api/client';
import type { FileItem } from '$lib/api/types';
export interface PhotoPage {
items: FileItem[];
nextCursor: string | null;
}
/**
* Fetch one page of the photo timeline. The next-page cursor is returned in the
* `X-Next-Cursor` response header; the page is the last one when fewer than
* `limit` items come back.
*/
export async function fetchPhotos(limit = 60, before?: string | null): Promise<PhotoPage> {
let url = `/api/photos?limit=${limit}`;
if (before) url += `&before=${encodeURIComponent(before)}`;
const res = await apiFetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`photos failed: ${res.status}`);
const items = (await res.json()) as FileItem[];
const cursor = res.headers.get('X-Next-Cursor');
return {
items: items ?? [],
nextCursor: cursor && items && items.length >= limit ? cursor : null
};
}
+45
View File
@@ -0,0 +1,45 @@
/** Profile / account endpoints — ported from views/profile/profile.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export interface ProfilePatch {
username?: string;
given_name?: string;
family_name?: string;
preferred_locale?: string;
notify_on_share?: boolean;
}
export async function updateProfile(patch: ProfilePatch): Promise<User> {
const res = await apiFetch('/api/auth/me/profile', {
method: 'PATCH',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(patch)
});
if (!res.ok) throw new Error(`profile update failed: ${res.status}`);
return (await res.json()) as User;
}
export async function changePassword(currentPw: string, newPw: string): Promise<void> {
const res = await apiFetch('/api/auth/change-password', {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
});
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
}
export async function updateAvatar(image: string): Promise<void> {
const res = await apiFetch('/api/auth/me/image', {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ image })
});
if (!res.ok) throw new Error(`avatar update failed: ${res.status}`);
}
+32
View File
@@ -0,0 +1,32 @@
/** Recent endpoints — ported from recentModel.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import {
fetchResourcePage,
type ResourceBody,
type ResourcePage,
type ResourcePageOpts
} from './resources';
import type { ItemType } from '$lib/api/types';
export interface RecentResourceItem {
resource_type: ItemType;
accessed_at: string;
resource: ResourceBody;
}
export function fetchRecentPage(
opts?: ResourcePageOpts
): Promise<ResourcePage<RecentResourceItem>> {
return fetchResourcePage<RecentResourceItem>('/api/recent/resources', 'accessed_at', opts);
}
export async function clearRecent(): Promise<void> {
const res = await apiFetch('/api/recent/clear', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`clear recent failed: ${res.status}`);
}
@@ -0,0 +1,42 @@
/**
* Shared cursor-pagination helper for the favorites/recent/trash "resources"
* endpoints, which all take the same query params. Ported from the legacy
* favoritesModel/recentModel/trashModel.
*/
import { apiFetch } from '$lib/api/client';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
export interface ResourcePageOpts {
cursor?: string;
orderBy?: string;
limit?: number;
reverse?: boolean;
resourceTypes?: ItemType[];
}
export interface ResourcePage<TItem> {
items: TItem[];
next_cursor?: string;
}
export type ResourceBody = FileItem | FolderItem;
export function buildResourceParams(opts: ResourcePageOpts, defaultOrderBy: string): string {
const { cursor, orderBy = defaultOrderBy, limit = 50, reverse = false, resourceTypes } = opts;
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
if (cursor) params.set('cursor', cursor);
if (reverse) params.set('reverse', 'true');
if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(','));
return params.toString();
}
export async function fetchResourcePage<TItem>(
base: string,
defaultOrderBy: string,
opts: ResourcePageOpts = {}
): Promise<ResourcePage<TItem>> {
const qs = buildResourceParams(opts, defaultOrderBy);
const res = await apiFetch(`${base}?${qs}`, { credentials: 'same-origin', cache: 'no-store' });
if (!res.ok) throw new Error(`GET ${base} failed: ${res.status}`);
return (await res.json()) as ResourcePage<TItem>;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Public share endpoints (/api/s/{token}). These intentionally run through
* apiFetch, which bypasses the refresh-and-retry path for /api/s/ — a 401 here
* means "password required", not "session expired".
*/
import { apiFetch } from '$lib/api/client';
import type { ItemType } from '$lib/api/types';
export interface ShareMeta {
item_type: ItemType;
item_name: string;
}
export interface ShareFolderEntry {
id: string;
name: string;
}
export interface ShareFileEntry {
id: string;
name: string;
mime_type?: string;
size?: number;
}
export interface ShareListing {
folders: ShareFolderEntry[];
files: ShareFileEntry[];
}
export type ShareMetaResult =
| { status: 'ok'; data: ShareMeta }
| { status: 'password' }
| { status: 'expired' };
const enc = encodeURIComponent;
export async function getShareMeta(token: string): Promise<ShareMetaResult> {
const res = await apiFetch(`/api/s/${enc(token)}`);
if (res.ok) return { status: 'ok', data: (await res.json()) as ShareMeta };
if (res.status === 401) {
const body = (await res.json().catch(() => null)) as { requiresPassword?: boolean } | null;
if (body?.requiresPassword) return { status: 'password' };
throw new Error('Unauthorized');
}
if (res.status === 410) return { status: 'expired' };
throw new Error(`HTTP ${res.status}`);
}
/** Returns true on success, false on incorrect password. */
export async function verifySharePassword(token: string, password: string): Promise<boolean> {
const res = await apiFetch(`/api/s/${enc(token)}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) return true;
if (res.status === 401) return false;
throw new Error(`HTTP ${res.status}`);
}
export type ShareListingResult =
| { status: 'ok'; data: ShareListing }
| { status: 'password' }
| { status: 'expired' };
export async function getShareContents(
token: string,
folderId?: string
): Promise<ShareListingResult> {
const url = folderId
? `/api/s/${enc(token)}/contents/${enc(folderId)}`
: `/api/s/${enc(token)}/contents`;
const res = await apiFetch(url);
if (res.ok) return { status: 'ok', data: (await res.json()) as ShareListing };
if (res.status === 401) return { status: 'password' };
if (res.status === 410 || res.status === 404) return { status: 'expired' };
throw new Error(`HTTP ${res.status}`);
}
export function shareDownloadUrl(token: string): string {
return `/api/s/${enc(token)}/download`;
}
export function shareFileUrl(token: string, fileId: string): string {
return `/api/s/${enc(token)}/file/${enc(fileId)}`;
}
export function shareZipUrl(token: string, folderId?: string): string {
return folderId ? `/api/s/${enc(token)}/zip/${enc(folderId)}` : `/api/s/${enc(token)}/zip`;
}
+37
View File
@@ -0,0 +1,37 @@
/** Trash endpoints — ported from trashModel.js + views/trash. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { fetchResourcePage, type ResourcePage, type ResourcePageOpts } from './resources';
import type { TrashResourceItem } from '$lib/api/types';
export function fetchTrashPage(opts?: ResourcePageOpts): Promise<ResourcePage<TrashResourceItem>> {
return fetchResourcePage<TrashResourceItem>('/api/trash/resources', 'deletion_date', opts);
}
export async function restoreTrashItem(trashId: string): Promise<void> {
const res = await apiFetch(`/api/trash/${trashId}/restore`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`restore failed: ${res.status}`);
}
export async function deleteTrashItem(trashId: string): Promise<void> {
const res = await apiFetch(`/api/trash/${trashId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`permanent delete failed: ${res.status}`);
}
export async function emptyTrash(): Promise<void> {
const res = await apiFetch('/api/trash/empty', {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
}
+198
View File
@@ -0,0 +1,198 @@
/**
* API wire types — ported from static/js/core/types.js.
*
* This is a focused, hand-ported subset covering the core resources. The plan
* is to regenerate the full set from the backend OpenAPI (`just openapi` +
* `openapi-typescript`) so these track the Rust DTOs; until then, extend here.
*/
export type ItemType = 'file' | 'folder';
export interface LightItem {
id: string;
name: string;
type: ItemType;
parentId: string;
}
export interface FolderItem {
category: string;
created_at: number;
icon_class: string;
icon_special_class: string;
id: string;
is_root: boolean;
modified_at: number;
name: string;
owner_id: string;
parent_id: string | null;
path: string;
etag: string;
}
export interface FileItem {
category: string;
created_at: number;
icon_class: string;
icon_special_class: string;
id: string;
mime_type: string;
modified_at: number;
name: string;
owner_id: string;
folder_id: string;
path: string;
size: number;
size_formatted: string;
sort_date: number;
etag: string;
content_hash: string;
/** Search-only: plain-text fragment around a content match. */
snippet?: string;
/** Search-only: "name" or "content". */
match_source?: string;
}
export interface ShareItem {
access_count: number;
created_at: number;
created_by: string;
expires_at: number;
has_password: boolean;
id: string;
item_id: string;
item_name: string;
item_type: ItemType;
token: string | null;
url: string;
}
export interface CreateShare {
item_id: string;
item_name?: string | null;
item_type: ItemType;
password: string | null;
expires_at: number | null;
}
export interface UpdateShare {
password?: string | null;
expires_at?: number | null;
}
export interface FavoriteItem {
id: string;
user_id: string;
item_id: string;
item_type: ItemType;
created_at: number;
item_name: string | null;
item_size: number | null;
item_mime_type: string | null;
parent_id: string | null;
modified_at: number | null;
item_path: string;
icon_class: string;
icon_special_class: string;
category: string;
size_formatted: string;
owner_id: string | null;
}
export interface RecentItem {
id: string;
user_id: string;
item_id: string;
item_type: ItemType;
accessed_at: number;
item_name: string | null;
item_size: number | null;
item_mime_type: string | null;
parent_id: string | null;
item_path: string;
icon_class: string;
icon_special_class: string;
category: string;
size_formatted: string;
}
export interface TrashResourceItem {
resource_type: ItemType;
trashed_at: string;
deletion_date: string;
resource: FileItem | FolderItem;
}
export interface TrashResourcesResponse {
items: TrashResourceItem[];
next_cursor?: string;
}
export type Role = 'user' | 'admin';
/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */
export interface User {
id: string;
username?: string;
email: string;
role: string;
storage_quota_bytes: number;
storage_used_bytes: number;
created_at: string;
updated_at: string;
last_login_at?: string | null;
active: boolean;
auth_provider: string;
image?: string | null;
can_edit_image: boolean;
is_external: boolean;
given_name?: string;
family_name?: string;
email_verified_at?: string;
preferred_locale?: string;
notify_on_share: boolean;
}
export interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
export type SortBy =
| 'relevance'
| 'name'
| 'name_desc'
| 'date'
| 'date_desc'
| 'size'
| 'size_desc';
export interface SearchCriteria {
sort_by: SortBy;
recursive: boolean;
limit: number;
offset: number;
name_contains?: string;
file_types?: string[];
folder_id?: string;
min_size?: number;
max_size?: number;
created_before?: number;
created_after?: number;
modified_before?: number;
modified_after?: number;
}
export interface SearchResults {
files: FileItem[];
folders: FolderItem[];
total_count: number | null;
limit: number;
offset: number;
has_more: boolean;
query_time_ms: number;
sort_by: string;
}