init new frontend
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 } : {};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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: '{}'
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user