visual continunity
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Admin endpoints — ported from views/admin/admin.js. Covers users, plugins
|
||||
* (incl. logs/retention/live SSE tail), dashboard, settings (OIDC/storage/SMTP),
|
||||
* and storage migration (incl. the verify integrity check).
|
||||
*/
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
@@ -38,7 +38,8 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
|
||||
export interface CreateUserInput {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
/** Optional — the backend auto-generates an address when null/empty. */
|
||||
email: string | null;
|
||||
role: string;
|
||||
quota_bytes: number;
|
||||
}
|
||||
@@ -67,6 +68,207 @@ export function deleteUser(userId: string): Promise<void> {
|
||||
return mutate(`/api/admin/users/${userId}`, 'DELETE');
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdminDashboard {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
admin_users: number;
|
||||
server_version: string;
|
||||
total_used_bytes: number;
|
||||
total_quota_bytes: number;
|
||||
storage_usage_percent: number;
|
||||
auth_enabled: boolean;
|
||||
oidc_configured: boolean;
|
||||
quotas_enabled: boolean;
|
||||
registration_enabled?: boolean;
|
||||
users_over_80_percent: number;
|
||||
users_over_quota: number;
|
||||
}
|
||||
|
||||
export function getDashboard(): Promise<AdminDashboard> {
|
||||
return apiJson<AdminDashboard>('/api/admin/dashboard', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export function setRegistrationEnabled(enabled: boolean): Promise<void> {
|
||||
return mutate('/api/admin/settings/registration', 'PUT', { registration_enabled: enabled });
|
||||
}
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SmtpInfo {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: string;
|
||||
from: string;
|
||||
user_state: string;
|
||||
}
|
||||
|
||||
export function getSmtpInfo(): Promise<SmtpInfo> {
|
||||
return apiJson<SmtpInfo>('/api/admin/smtp/info', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export interface SmtpTestResult {
|
||||
success: boolean;
|
||||
code?: string | number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of POST .../settings/storage/test — the S3 connection probe. */
|
||||
export interface StorageTestResult {
|
||||
connected?: boolean;
|
||||
success?: boolean;
|
||||
backend_type?: string;
|
||||
available_bytes?: number | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function sendSmtpTest(to: string): Promise<SmtpTestResult> {
|
||||
const res = await apiFetch('/api/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ to })
|
||||
});
|
||||
if (res.status === 503)
|
||||
return { success: false, message: 'SMTP is not configured on this server.' };
|
||||
return (await res.json().catch(() => ({ success: false }))) as SmtpTestResult;
|
||||
}
|
||||
|
||||
// ── OIDC settings ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface OidcSettings {
|
||||
enabled: boolean;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
scopes: string | null;
|
||||
auto_provision: boolean;
|
||||
admin_groups: string | null;
|
||||
disable_password_login: boolean;
|
||||
provider_name: string | null;
|
||||
callback_url?: string;
|
||||
client_secret_set?: boolean;
|
||||
env_overrides?: string[];
|
||||
}
|
||||
|
||||
export interface OidcTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
issuer?: string;
|
||||
authorization_endpoint?: string;
|
||||
provider_name_suggestion?: string;
|
||||
}
|
||||
|
||||
export function getOidcSettings(): Promise<OidcSettings> {
|
||||
return apiJson<OidcSettings>('/api/admin/settings/oidc', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export async function testOidc(issuerUrl: string): Promise<OidcTestResult> {
|
||||
const res = await apiFetch('/api/admin/settings/oidc/test', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ issuer_url: issuerUrl })
|
||||
});
|
||||
return (await res
|
||||
.json()
|
||||
.catch(() => ({ success: false, message: 'Request failed' }))) as OidcTestResult;
|
||||
}
|
||||
|
||||
export function saveOidc(body: Record<string, unknown>): Promise<void> {
|
||||
return mutate('/api/admin/settings/oidc', 'PUT', body);
|
||||
}
|
||||
|
||||
// ── Storage settings + migration ───────────────────────────────────────────
|
||||
|
||||
export interface StorageSettings {
|
||||
backend: string;
|
||||
s3_endpoint_url?: string | null;
|
||||
s3_bucket?: string | null;
|
||||
s3_region?: string | null;
|
||||
s3_access_key_set?: boolean;
|
||||
s3_secret_key_set?: boolean;
|
||||
s3_force_path_style?: boolean;
|
||||
env_overrides?: string[];
|
||||
current_backend?: string;
|
||||
total_blobs?: number;
|
||||
total_bytes_stored?: number;
|
||||
dedup_ratio?: number;
|
||||
}
|
||||
|
||||
export function getStorageSettings(): Promise<StorageSettings> {
|
||||
return apiJson<StorageSettings>('/api/admin/settings/storage', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export function saveStorage(body: Record<string, unknown>): Promise<void> {
|
||||
return mutate('/api/admin/settings/storage', 'PUT', body);
|
||||
}
|
||||
|
||||
export async function testStorage(body: Record<string, unknown>): Promise<StorageTestResult> {
|
||||
const res = await apiFetch('/api/admin/settings/storage/test', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return (await res.json().catch(() => ({ connected: false }))) as StorageTestResult;
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
status: 'idle' | 'running' | 'paused' | 'completed' | 'failed';
|
||||
total_blobs: number;
|
||||
migrated_blobs: number;
|
||||
migrated_bytes: number;
|
||||
throughput_bytes_per_sec?: number;
|
||||
failed_blobs?: string[];
|
||||
}
|
||||
|
||||
export function getMigration(): Promise<MigrationStatus> {
|
||||
return apiJson<MigrationStatus>('/api/admin/storage/migration', { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export function migrationAction(action: 'start' | 'pause' | 'resume' | 'complete'): Promise<void> {
|
||||
const body = action === 'start' ? { concurrency: 4 } : {};
|
||||
return mutate(`/api/admin/storage/migration/${action}`, 'POST', body);
|
||||
}
|
||||
|
||||
/** Result of a `verify` integrity check (POST .../migration/verify). */
|
||||
export interface MigrationVerifyResult {
|
||||
passed: boolean;
|
||||
sample_checked: number;
|
||||
pg_blob_count: number;
|
||||
missing_in_target: string[];
|
||||
size_mismatches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an integrity verification pass over a sample of migrated blobs. Unlike
|
||||
* the other migration actions this returns a structured result that the caller
|
||||
* renders (passed / sample-checked / missing / size-mismatch counts).
|
||||
*/
|
||||
export async function verifyMigration(sampleSize = 100): Promise<MigrationVerifyResult> {
|
||||
const res = await apiFetch('/api/admin/storage/migration/verify', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ sample_size: sampleSize })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(e.message || `verify failed: ${res.status}`);
|
||||
}
|
||||
const r = (await res.json()) as Partial<MigrationVerifyResult>;
|
||||
return {
|
||||
passed: r.passed ?? false,
|
||||
sample_checked: r.sample_checked ?? 0,
|
||||
pg_blob_count: r.pg_blob_count ?? 0,
|
||||
missing_in_target: r.missing_in_target ?? [],
|
||||
size_mismatches: r.size_mismatches ?? []
|
||||
};
|
||||
}
|
||||
|
||||
// ── Plugins ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PluginInfo {
|
||||
@@ -75,6 +277,49 @@ export interface PluginInfo {
|
||||
version?: string;
|
||||
enabled: boolean;
|
||||
description?: string;
|
||||
abi?: string | number;
|
||||
subscriptions?: string[];
|
||||
}
|
||||
|
||||
export interface PluginRetention {
|
||||
retention_days: number;
|
||||
max_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a plugin from a .zip bundle. The browser sets the multipart
|
||||
* Content-Type (with boundary) — do not override it here.
|
||||
*/
|
||||
export async function installPlugin(bundle: File): Promise<PluginInfo> {
|
||||
const form = new FormData();
|
||||
form.append('bundle', bundle);
|
||||
const res = await apiFetch('/api/admin/plugins', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...getCsrfHeaders() },
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(e.message || `install failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as PluginInfo;
|
||||
}
|
||||
|
||||
export async function getPluginRetention(id: string): Promise<PluginRetention | null> {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as PluginRetention;
|
||||
}
|
||||
|
||||
export function savePluginRetention(id: string, r: PluginRetention): Promise<void> {
|
||||
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, 'PUT', r);
|
||||
}
|
||||
|
||||
export function clearPluginLogs(id: string): Promise<void> {
|
||||
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/logs`, 'DELETE');
|
||||
}
|
||||
|
||||
export interface PluginsResult {
|
||||
@@ -99,3 +344,36 @@ export function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
export function deletePlugin(id: string): Promise<void> {
|
||||
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}`, 'DELETE');
|
||||
}
|
||||
|
||||
export interface PluginLogEntry {
|
||||
timestamp?: string;
|
||||
ts?: string;
|
||||
level?: string;
|
||||
message?: string;
|
||||
/** Streamed-entry message field (SSE / persisted logs use `msg`). */
|
||||
msg?: string;
|
||||
/** "outcome" | "log" — outcome entries carry a `reason`. */
|
||||
kind?: string;
|
||||
reason?: string;
|
||||
invocation_id?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PluginLogPage {
|
||||
total: number;
|
||||
entries: PluginLogEntry[];
|
||||
}
|
||||
|
||||
export function getPluginLogs(
|
||||
id: string,
|
||||
opts: { limit?: number; offset?: number; level?: string; search?: string } = {}
|
||||
): Promise<PluginLogPage> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(opts.limit ?? 50));
|
||||
params.set('offset', String(opts.offset ?? 0));
|
||||
if (opts.level) params.set('level', opts.level);
|
||||
if (opts.search) params.set('search', opts.search);
|
||||
return apiJson<PluginLogPage>(`/api/admin/plugins/${encodeURIComponent(id)}/logs?${params}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,127 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
return (await res.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
export interface OidcProviders {
|
||||
enabled: boolean;
|
||||
provider_name?: string;
|
||||
password_login_enabled?: boolean;
|
||||
authorize_endpoint?: string;
|
||||
}
|
||||
|
||||
/** Public OIDC provider info for the login page. */
|
||||
export async function getOidcProviders(): Promise<OidcProviders> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/oidc/providers');
|
||||
if (!res.ok) return { enabled: false };
|
||||
return (await res.json()) as OidcProviders;
|
||||
} catch {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
initialized: boolean;
|
||||
admin_count: number;
|
||||
registration_allowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* System bootstrap probe. When `initialized === false` no admin exists yet and
|
||||
* the login page must offer the first-run admin-setup flow. Raw `fetch` (NOT
|
||||
* apiFetch): this is unauthenticated and a non-2xx must not bounce through the
|
||||
* refresh interceptor. Defaults to "initialized" on any failure so a transient
|
||||
* error never strands operators on the setup wizard.
|
||||
*/
|
||||
export async function getAuthStatus(): Promise<AuthStatus> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
if (!res.ok) return { initialized: true, admin_count: 1, registration_allowed: true };
|
||||
return (await res.json()) as AuthStatus;
|
||||
} catch {
|
||||
return { initialized: true, admin_count: 1, registration_allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run admin bootstrap. POSTs to `/api/setup`, which creates the admin
|
||||
* user and marks the system initialized. Raw `fetch` (NOT apiFetch) so a 401
|
||||
* surfaces as a genuine failure instead of triggering the refresh-and-redirect.
|
||||
*/
|
||||
export async function setupAdmin(email: string, password: string): Promise<void> {
|
||||
const res = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username: 'admin', email, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(e.error || e.message || `setup failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC code-exchange fallback. When the IdP round-trip lands back on the login
|
||||
* page with `?oidc_code=`, exchange it for a session (cookies are set
|
||||
* server-side). Raw `fetch` (NOT apiFetch) — a 401 here is a genuine exchange
|
||||
* failure, not an expired access token. Returns the user on success, null on
|
||||
* any failure so the caller can fall through to the normal login UI.
|
||||
*/
|
||||
export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
try {
|
||||
const res = await fetch('/api/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { user?: User };
|
||||
return data.user ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
||||
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
||||
* interceptor — mirrors the login primitive.
|
||||
*/
|
||||
export async function register(username: string, email: string, password: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username, email, password, role: 'user' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(e.error || e.message || `register failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type MagicLinkResult = 'sent' | 'unavailable';
|
||||
|
||||
/**
|
||||
* Anti-enumeration sign-in by email. Any 2xx resolves to `sent` with a uniform
|
||||
* message regardless of whether the email maps to an account. 503 means SMTP
|
||||
* isn't configured (`unavailable`) — operators need to see that. Other non-2xx
|
||||
* throw so the caller can show a generic error. Raw `fetch` (NOT apiFetch):
|
||||
* unauthenticated, must not enter the refresh interceptor.
|
||||
*/
|
||||
export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
|
||||
const res = await fetch('/api/auth/magic-link/send', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
if (res.status === 503) return 'unavailable';
|
||||
if (!res.ok) throw new Error(`magic-link failed: ${res.status}`);
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiFetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Batch operations (/api/batch/*). Used for multi-item copy — move and delete
|
||||
* already have per-item endpoints the files view loops over, but copy only
|
||||
* exists as a batch endpoint on the backend.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function post(url: string, body: unknown): Promise<void> {
|
||||
const res = await apiFetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(e.error || e.message || `${url} failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function copyFiles(fileIds: string[], targetFolderId: string | null): Promise<void> {
|
||||
if (fileIds.length === 0) return Promise.resolve();
|
||||
return post('/api/batch/files/copy', { file_ids: fileIds, target_folder_id: targetFolderId });
|
||||
}
|
||||
|
||||
export function copyFolders(folderIds: string[], targetFolderId: string | null): Promise<void> {
|
||||
if (folderIds.length === 0) return Promise.resolve();
|
||||
return post('/api/batch/folders/copy', {
|
||||
folder_ids: folderIds,
|
||||
target_folder_id: targetFolderId
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Delta upload ("upload only what changed") — ported from
|
||||
* features/files/deltaUpload.js. Main-thread orchestrator for
|
||||
* `/static/workers/deltaWorker.js`, which runs FastCDC chunking + BLAKE3
|
||||
* (the same WASM crate/params as the server) off the UI thread, negotiates
|
||||
* which chunks the server already has, uploads only the missing ones, and
|
||||
* commits. Any failure resolves `null` so the caller falls back to a plain
|
||||
* byte upload — delta is an optimization, never a gate.
|
||||
*/
|
||||
import { getCsrfToken } from '$lib/api/csrf';
|
||||
|
||||
/** Files smaller than this skip delta: the round-trips cost more than the bytes. */
|
||||
export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
|
||||
|
||||
const DELTA_WORKER_URL = '/workers/deltaWorker.js';
|
||||
const DELTA_TIMEOUT_BASE_MS = 120_000;
|
||||
const DELTA_TIMEOUT_PER_GB_MS = 90_000;
|
||||
|
||||
export interface DeltaUploadAnswer {
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
errorMsg?: string;
|
||||
isQuotaError?: boolean;
|
||||
/** Bytes NOT transferred thanks to dedup. */
|
||||
savedBytes?: number;
|
||||
}
|
||||
|
||||
/** `false` once the environment proved unable to run the worker/WASM. */
|
||||
let usable: boolean | null = null;
|
||||
|
||||
interface ProgressMsg {
|
||||
type: 'progress';
|
||||
reusedBytes: number;
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
interface FallbackMsg {
|
||||
type: 'fallback';
|
||||
reason?: string;
|
||||
}
|
||||
interface DoneMsg {
|
||||
type: 'done';
|
||||
status: number;
|
||||
body?: { message?: string; error?: string; still_missing?: unknown };
|
||||
}
|
||||
type WorkerMsg = ProgressMsg | FallbackMsg | DoneMsg;
|
||||
|
||||
/**
|
||||
* Try to upload `file` through the delta protocol. Resolves `null` whenever
|
||||
* the plain byte upload should proceed (too small, environment unusable, any
|
||||
* transport/protocol failure). `onProgress` receives 0–99 while transferring.
|
||||
*/
|
||||
export function tryDeltaUpload(
|
||||
file: File,
|
||||
folderId: string | null | undefined,
|
||||
onProgress?: (pct: number) => void
|
||||
): Promise<DeltaUploadAnswer | null> {
|
||||
if (
|
||||
!folderId ||
|
||||
file.size < DELTA_UPLOAD_MIN_SIZE ||
|
||||
usable === false ||
|
||||
typeof Worker === 'undefined'
|
||||
) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = new Worker(DELTA_WORKER_URL, { type: 'module' });
|
||||
} catch {
|
||||
usable = false;
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeGB = file.size / (1024 * 1024 * 1024);
|
||||
const timeoutMs = DELTA_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * DELTA_TIMEOUT_PER_GB_MS;
|
||||
let savedBytes = 0;
|
||||
|
||||
const settle = (answer: DeltaUploadAnswer | null) => {
|
||||
clearTimeout(timer);
|
||||
worker.terminate();
|
||||
resolve(answer);
|
||||
};
|
||||
const timer = setTimeout(() => settle(null), timeoutMs);
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerMsg>) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'progress') {
|
||||
savedBytes = msg.reusedBytes;
|
||||
if (onProgress && msg.totalBytes > 0) {
|
||||
const pct = Math.min(
|
||||
99,
|
||||
Math.round((100 * (msg.reusedBytes + msg.uploadedBytes)) / msg.totalBytes)
|
||||
);
|
||||
onProgress(pct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'fallback') {
|
||||
settle(null);
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'done') {
|
||||
if (msg.status === 201 || msg.status === 200) {
|
||||
settle({ ok: true, data: msg.body, savedBytes });
|
||||
return;
|
||||
}
|
||||
const errorMsg =
|
||||
msg.body?.message || msg.body?.error || `Delta upload failed (HTTP ${msg.status})`;
|
||||
if (msg.status === 507) {
|
||||
settle({ ok: false, isQuotaError: true, errorMsg });
|
||||
return;
|
||||
}
|
||||
if (msg.status === 409 && !msg.body?.still_missing) {
|
||||
settle({ ok: false, errorMsg });
|
||||
return;
|
||||
}
|
||||
settle(null);
|
||||
}
|
||||
};
|
||||
worker.onerror = () => {
|
||||
usable = false;
|
||||
settle(null);
|
||||
};
|
||||
|
||||
worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' });
|
||||
});
|
||||
}
|
||||
@@ -7,12 +7,32 @@ export interface DeviceInfo {
|
||||
scopes?: string;
|
||||
}
|
||||
|
||||
/** Distinguishable failure modes the verify page renders differently. */
|
||||
export type DeviceLookupError = 'unauthorized' | 'not-found' | 'failed';
|
||||
|
||||
/** Thrown by lookupDeviceCode so the page can show a tailored message. */
|
||||
export class DeviceLookupFailure extends Error {
|
||||
constructor(readonly kind: DeviceLookupError) {
|
||||
super(kind);
|
||||
this.name = 'DeviceLookupFailure';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a device user-code. The backend returns HTTP 200 with `{valid:false}`
|
||||
* for unknown/expired codes (NOT a non-2xx), so the body must be inspected — a
|
||||
* 2xx alone does not mean the code is good. A 401 means the caller isn't signed
|
||||
* in and must authenticate before authorizing a device.
|
||||
*/
|
||||
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;
|
||||
if (res.status === 401) throw new DeviceLookupFailure('unauthorized');
|
||||
if (!res.ok) throw new DeviceLookupFailure('failed');
|
||||
const data = (await res.json()) as DeviceInfo & { valid?: boolean };
|
||||
if (data.valid === false) throw new DeviceLookupFailure('not-found');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function decideDevice(userCode: string, action: 'approve' | 'deny'): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Favorites endpoints — ported from favoritesModel.js + features/library. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import {
|
||||
fetchResourcePage,
|
||||
type ResourceBody,
|
||||
@@ -9,12 +10,111 @@ import {
|
||||
} from './resources';
|
||||
import type { ItemType } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* Coarse "how long ago" bucket for date group-bys (favorited/accessed/modified)
|
||||
* — ported from `normalizeDateBucket` in static/js/core/formatters.js.
|
||||
*/
|
||||
export function dateBucket(value: number | string | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
let date: Date;
|
||||
if (typeof value === 'number') date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
else date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
|
||||
if (diffDays <= 0) return t('dateBucket.today', 'Today');
|
||||
if (diffDays <= 7) return t('dateBucket.last7days', 'Last 7 days');
|
||||
if (diffDays <= 30) return t('dateBucket.last30days', 'Last 30 days');
|
||||
return String(date.getFullYear());
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse size bucket label — ported from `sizeBucket`. Pass `null` for folders
|
||||
* (they receive the "Folders" label).
|
||||
*/
|
||||
export function sizeBucket(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return t('sizeBucket.folders', 'Folders');
|
||||
if (bytes === 0) return t('sizeBucket.empty', 'Empty (0 B)');
|
||||
if (bytes < 1_048_576) return t('sizeBucket.tiny', '< 1 MB');
|
||||
if (bytes < 104_857_600) return t('sizeBucket.small', '1 – 100 MB');
|
||||
if (bytes < 1_073_741_824) return t('sizeBucket.medium', '100 MB – 1 GB');
|
||||
if (bytes < 5 * 1_073_741_824) return t('sizeBucket.large', '1 – 5 GB');
|
||||
return t('sizeBucket.huge', '> 5 GB');
|
||||
}
|
||||
|
||||
/** Human label for a resource `category` / type group-by bucket. */
|
||||
export function typeLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Folder: t('groupby.type.folders', 'Folders'),
|
||||
Image: t('category.images', 'Images'),
|
||||
Video: t('category.videos', 'Videos'),
|
||||
Audio: t('category.audio', 'Audio'),
|
||||
PDF: 'PDF',
|
||||
Document: t('category.documents', 'Documents'),
|
||||
Spreadsheet: t('category.spreadsheets', 'Spreadsheets'),
|
||||
Presentation: t('category.presentations', 'Presentations'),
|
||||
Archive: t('category.archives', 'Archives'),
|
||||
Code: t('category.code', 'Code'),
|
||||
Markdown: t('category.markdown', 'Markdown'),
|
||||
Text: t('category.text', 'Text'),
|
||||
Installer: t('category.installers', 'Installers')
|
||||
};
|
||||
return labels[category] ?? category;
|
||||
}
|
||||
|
||||
export interface FavoritesResourceItem {
|
||||
resource_type: ItemType;
|
||||
favorited_at: string;
|
||||
resource: ResourceBody;
|
||||
}
|
||||
|
||||
/** userId → resolved display name (best-effort, cached across the session). */
|
||||
const ownerNameCache = new Map<string, string>();
|
||||
const ownerInflight = new Map<string, Promise<string>>();
|
||||
|
||||
function shortId(id: string): string {
|
||||
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort owner display-name lookup via `/api/users/{id}`, de-duplicated
|
||||
* and cached. Falls back to a shortened UUID on any failure. Ported from the
|
||||
* `systemUsers` resolver in the legacy frontend.
|
||||
*/
|
||||
export async function resolveOwnerName(ownerId: string): Promise<string> {
|
||||
if (!ownerId) return '';
|
||||
const cached = ownerNameCache.get(ownerId);
|
||||
if (cached) return cached;
|
||||
const pending = ownerInflight.get(ownerId);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
let name = shortId(ownerId);
|
||||
try {
|
||||
const res = await apiFetch(`/api/users/${encodeURIComponent(ownerId)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (res.ok) {
|
||||
const u = (await res.json()) as {
|
||||
username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
email?: string;
|
||||
};
|
||||
const full = [u.given_name, u.family_name].filter(Boolean).join(' ').trim();
|
||||
name = u.username || full || u.email || name;
|
||||
}
|
||||
} catch {
|
||||
// keep the UUID fallback
|
||||
} finally {
|
||||
ownerInflight.delete(ownerId);
|
||||
}
|
||||
ownerNameCache.set(ownerId, name);
|
||||
return name;
|
||||
})();
|
||||
ownerInflight.set(ownerId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function fetchFavoritesPage(
|
||||
opts?: ResourcePageOpts
|
||||
): Promise<ResourcePage<FavoritesResourceItem>> {
|
||||
|
||||
@@ -18,6 +18,36 @@ export async function uploadFile(folderId: string | null, file: File): Promise<v
|
||||
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload with progress reporting. `fetch` can't surface upload progress, so this
|
||||
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
|
||||
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
|
||||
*/
|
||||
export function uploadFileWithProgress(
|
||||
folderId: string | null,
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const form = new FormData();
|
||||
if (folderId) form.append('folder_id', folderId);
|
||||
form.append('file', file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/files/upload');
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`upload failed: ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('upload failed: network error'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameFile(fileId: string, name: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/files/${fileId}/rename`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1,8 +1,140 @@
|
||||
/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { ItemType } from '$lib/api/types';
|
||||
import type { ResourceBody, ResourcePage } from './resources';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
export type SubjectType = 'user' | 'group' | 'email' | 'token';
|
||||
export type ShareRole = 'viewer' | 'editor' | 'admin';
|
||||
|
||||
export interface GrantSubject {
|
||||
type: SubjectType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject shape accepted by `POST /api/grants`. The `email` variant feeds the
|
||||
* invite-by-email flow — the server resolves it to (or provisions) an external
|
||||
* user. Mirrors the backend `SubjectInputDto`.
|
||||
*/
|
||||
export type GrantSubjectInput =
|
||||
| { type: 'user'; id: string }
|
||||
| { type: 'group'; id: string }
|
||||
| { type: 'token'; id: string }
|
||||
| { type: 'email'; email: string };
|
||||
|
||||
/** One grant carries a single permission; a subject's role is derived from all of theirs. */
|
||||
export interface Grant {
|
||||
id: string;
|
||||
granted_at?: string;
|
||||
granted_by?: string;
|
||||
subject: GrantSubject;
|
||||
permission: string;
|
||||
resource: { type: ItemType; id: string };
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Notification outcomes (PR N1/N2) ─────────────────────────────────────────
|
||||
|
||||
export interface NotifyOutcome {
|
||||
kind: 'sent' | 'coalesced' | 'rate_limited' | 'not_applicable';
|
||||
detail?: string;
|
||||
last_sent_at?: string;
|
||||
retry_after_secs?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface NotifyOutcomeSet {
|
||||
total_recipients: number;
|
||||
outcomes: NotifyOutcome[];
|
||||
}
|
||||
|
||||
export interface CreateGrantResponse {
|
||||
grants: Grant[];
|
||||
notification: NotifyOutcomeSet;
|
||||
}
|
||||
|
||||
export function roleFromPermissions(perms: Iterable<string>): ShareRole {
|
||||
const set = new Set(perms);
|
||||
if (set.has('delete') || set.has('share')) return 'admin';
|
||||
if (set.has('create') || set.has('update')) return 'editor';
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
/** Convert a YYYY-MM-DD date (or null) to an ISO-8601 datetime at midnight UTC. */
|
||||
export function expiryToIso(date: string | null | undefined): string | null {
|
||||
return date ? new Date(`${date}T00:00:00Z`).toISOString() : null;
|
||||
}
|
||||
|
||||
export function fetchGrantsForResource(type: ItemType, id: string): Promise<Grant[]> {
|
||||
const params = new URLSearchParams({ resource_type: type, resource_id: id });
|
||||
return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
export async function createGrant(
|
||||
subject: GrantSubjectInput,
|
||||
resource: { type: ItemType; id: string },
|
||||
role: ShareRole,
|
||||
expiresAt?: string | null
|
||||
): Promise<CreateGrantResponse> {
|
||||
const res = await apiFetch('/api/grants', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(e.error || `create grant failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as CreateGrantResponse;
|
||||
}
|
||||
|
||||
export async function updateGrantRole(
|
||||
subject: GrantSubject,
|
||||
resource: { type: ItemType; id: string },
|
||||
role: ShareRole,
|
||||
expiresAt?: string | null
|
||||
): Promise<void> {
|
||||
const res = await apiFetch('/api/grants/role', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null })
|
||||
});
|
||||
if (!res.ok) throw new Error(`update role failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function revokeGrant(grantId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`revoke grant failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend / send a share notification for a single grant.
|
||||
* `POST /api/grants/{id}/notify`. Returns the aggregated outcome set, or a
|
||||
* `rate_limited` summary when the whole call was rate-limited (HTTP 429).
|
||||
*/
|
||||
export async function notifyGrantRecipient(grantId: string): Promise<NotifyOutcomeSet> {
|
||||
const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}/notify`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (res.status === 204) return { total_recipients: 0, outcomes: [] };
|
||||
if (res.status === 429) {
|
||||
return { total_recipients: 1, outcomes: [{ kind: 'rate_limited' }] };
|
||||
}
|
||||
if (res.ok) return (await res.json()) as NotifyOutcomeSet;
|
||||
throw new Error(`notify failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface IncomingGrantItem {
|
||||
resource_type: ItemType;
|
||||
resource: ResourceBody;
|
||||
@@ -11,12 +143,25 @@ export interface IncomingGrantItem {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** One (subject, permissions) entry within an outgoing resource item. */
|
||||
export interface OutgoingResourceGrant {
|
||||
grant_id: string;
|
||||
subject_type: 'user' | 'group' | 'token';
|
||||
subject_id: string;
|
||||
subject_display: string;
|
||||
role: ShareRole;
|
||||
granted_at: string;
|
||||
expires_at?: string | null;
|
||||
has_password: boolean;
|
||||
is_external: boolean;
|
||||
}
|
||||
|
||||
export interface OutgoingGrantItem {
|
||||
resource_type: ItemType;
|
||||
resource: ResourceBody;
|
||||
subject?: string;
|
||||
first_shared_at?: string;
|
||||
role?: string;
|
||||
/** One entry per (subject, permissions) pair. */
|
||||
grants: OutgoingResourceGrant[];
|
||||
}
|
||||
|
||||
interface GrantsPageOpts {
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
/** Group (ReBAC) endpoints — ported from model/groups.js. */
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/**
|
||||
* Well-known UUID of the predefined "Internal" virtual group (matches the
|
||||
* Rust constant `INTERNAL_GROUP_ID` in `src/domain/entities/subject_group.rs`
|
||||
* and the legacy `model/groups.js`).
|
||||
*/
|
||||
export const INTERNAL_GROUP_ID = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
/**
|
||||
* Map of well-known virtual-group UUIDs → i18n key for the human-readable
|
||||
* display name. Anything not in this map falls back to `group.name`. Ported
|
||||
* from `components/groupDisplay.js`.
|
||||
*/
|
||||
const VIRTUAL_NAME_KEYS: Record<string, string> = {
|
||||
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_name'
|
||||
};
|
||||
|
||||
export interface GroupItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
member_count?: number;
|
||||
is_virtual?: boolean;
|
||||
can_manage?: boolean;
|
||||
}
|
||||
|
||||
/** The members endpoint returns a tagged union: `{ kind: 'user' | 'group', id }`. */
|
||||
export interface GroupMember {
|
||||
user_id?: string;
|
||||
group_id?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
kind: 'user' | 'group';
|
||||
id: string;
|
||||
}
|
||||
|
||||
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
|
||||
@@ -29,16 +47,53 @@ async function mutate(url: string, method: string, body?: unknown): Promise<void
|
||||
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[]> {
|
||||
/** A single page of groups plus the server-reported total (for "Load more"). */
|
||||
export interface GroupPage {
|
||||
items: GroupItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one page of groups. The list endpoint may return an array or
|
||||
* `{ groups | items, total }`. When no total is provided we fall back to the
|
||||
* page length so pagination collapses gracefully to a single page.
|
||||
*/
|
||||
export async function listGroupsPage(limit = 50, offset = 0, q?: string): Promise<GroupPage> {
|
||||
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 ?? [];
|
||||
const data = await apiJson<
|
||||
GroupItem[] | { groups?: GroupItem[]; items?: GroupItem[]; total?: number }
|
||||
>(`/api/groups?${params}`, { credentials: 'same-origin' });
|
||||
if (Array.isArray(data)) return { items: data, total: offset + data.length };
|
||||
const items = data.groups ?? data.items ?? [];
|
||||
return { items, total: data.total ?? offset + items.length };
|
||||
}
|
||||
|
||||
/** Convenience wrapper returning just the items of the first page. */
|
||||
export async function listGroups(limit = 50, offset = 0, q?: string): Promise<GroupItem[]> {
|
||||
return (await listGroupsPage(limit, offset, q)).items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable display name for a group. Virtual groups get a translated
|
||||
* label via the well-known UUID mapping; user-defined groups display their
|
||||
* raw name. Ported from `components/groupDisplay.js`.
|
||||
*/
|
||||
export function groupDisplayName(group: GroupItem): string {
|
||||
if (group.is_virtual) {
|
||||
const key = VIRTUAL_NAME_KEYS[group.id];
|
||||
if (key) return t(key, group.name);
|
||||
}
|
||||
return group.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the icon registry name for a group avatar. Virtual (system-wide)
|
||||
* groups use `people-roof`; user-defined groups use `user-group`. Ported from
|
||||
* `components/groupDisplay.js`.
|
||||
*/
|
||||
export function groupIconName(group: Pick<GroupItem, 'is_virtual'>): string {
|
||||
return group.is_virtual ? 'people-roof' : 'user-group';
|
||||
}
|
||||
|
||||
export function createGroup(name: string, description?: string | null): Promise<void> {
|
||||
@@ -61,6 +116,11 @@ export function addUserMember(groupId: string, userId: string): Promise<void> {
|
||||
return mutate(`/api/groups/${enc(groupId)}/members`, 'POST', { user_id: userId });
|
||||
}
|
||||
|
||||
/** Add another group as a nested member. Backend enforces cycle + depth limits. */
|
||||
export function addGroupMember(groupId: string, memberGroupId: string): Promise<void> {
|
||||
return mutate(`/api/groups/${enc(groupId)}/members`, 'POST', { group_id: memberGroupId });
|
||||
}
|
||||
|
||||
export function removeUserMember(groupId: string, userId: string): Promise<void> {
|
||||
return mutate(`/api/groups/${enc(groupId)}/members/user/${enc(userId)}`, 'DELETE');
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ export interface PlaylistItem {
|
||||
duration_secs: number | null;
|
||||
}
|
||||
|
||||
/** A user a playlist is shared with (`/api/playlists/{id}/shares`). */
|
||||
export interface MusicShare {
|
||||
user_id: string;
|
||||
can_write: boolean | null;
|
||||
}
|
||||
|
||||
/** Fields that can be patched on a playlist via PUT. */
|
||||
export interface PlaylistUpdate {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
is_public?: boolean;
|
||||
cover_file_id?: string | null;
|
||||
}
|
||||
|
||||
export function listPlaylists(): Promise<Playlist[]> {
|
||||
return apiJson<Playlist[]>('/api/playlists', { credentials: 'same-origin' });
|
||||
}
|
||||
@@ -53,14 +67,19 @@ export async function createPlaylist(name: string): Promise<Playlist> {
|
||||
return (await res.json()) as Playlist;
|
||||
}
|
||||
|
||||
export async function renamePlaylist(playlistId: string, name: string): Promise<void> {
|
||||
/** Patch one or more playlist fields (name, description, public flag, cover). */
|
||||
export async function updatePlaylist(playlistId: string, patch: PlaylistUpdate): Promise<void> {
|
||||
const res = await apiFetch(`/api/playlists/${playlistId}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ name })
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) throw new Error(`rename playlist failed: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`update playlist failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export function renamePlaylist(playlistId: string, name: string): Promise<void> {
|
||||
return updatePlaylist(playlistId, { name });
|
||||
}
|
||||
|
||||
export async function deletePlaylist(playlistId: string): Promise<void> {
|
||||
@@ -101,3 +120,49 @@ export async function reorderTracks(playlistId: string, itemIds: string[]): Prom
|
||||
});
|
||||
if (!res.ok) throw new Error(`reorder failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export function listShares(playlistId: string): Promise<MusicShare[]> {
|
||||
return apiJson<MusicShare[]>(`/api/playlists/${playlistId}/shares`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
export async function sharePlaylist(
|
||||
playlistId: string,
|
||||
userId: string,
|
||||
canWrite = false
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(`/api/playlists/${playlistId}/share`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ user_id: userId, can_write: canWrite })
|
||||
});
|
||||
if (!res.ok) throw new Error(`share playlist failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function removeShare(playlistId: string, userId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/playlists/${playlistId}/share/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`remove share failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/** Upload an image and return its new file id (used to set a playlist cover). */
|
||||
export async function uploadCoverImage(file: File, folderId = ''): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('folder_id', folderId);
|
||||
const res = await apiFetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders(),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) throw new Error(`cover upload failed: ${res.status}`);
|
||||
const uploaded = (await res.json()) as { id?: string };
|
||||
if (!uploaded.id) throw new Error('cover upload returned no file id');
|
||||
return uploaded.id;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Photos timeline endpoint — ported from features/library/photos.js. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { FileItem } from '$lib/api/types';
|
||||
|
||||
export interface PhotoPage {
|
||||
@@ -7,6 +8,28 @@ export interface PhotoPage {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** EXIF metadata returned by `/api/files/{id}/metadata` (subset used by the lightbox). */
|
||||
export interface FileMetadata {
|
||||
file_id: string;
|
||||
captured_at?: number;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
camera_make?: string | null;
|
||||
camera_model?: string | null;
|
||||
orientation?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
/** Result of a batch trash request (200 = all, 206 = partial success). */
|
||||
export interface BatchTrashResult {
|
||||
successful: string[];
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */
|
||||
const BATCH_CHUNK_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -24,3 +47,56 @@ export async function fetchPhotos(limit = 60, before?: string | null): Promise<P
|
||||
nextCursor: cursor && items && items.length >= limit ? cursor : null
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetch EXIF metadata for a file. Returns `null` on any error (non-critical). */
|
||||
export async function fetchFileMetadata(fileId: string): Promise<FileMetadata | null> {
|
||||
try {
|
||||
const res = await apiFetch(`/api/files/${fileId}/metadata`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as FileMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move files to trash in batches via `POST /api/batch/trash`. One request per
|
||||
* chunk (up to {@link BATCH_CHUNK_SIZE} ids); 200 = all trashed, 206 = partial.
|
||||
* Returns the set of ids that were actually trashed across all chunks.
|
||||
*/
|
||||
export async function batchTrash(fileIds: string[]): Promise<Set<string>> {
|
||||
const trashed = new Set<string>();
|
||||
for (let i = 0; i < fileIds.length; i += BATCH_CHUNK_SIZE) {
|
||||
const chunk = fileIds.slice(i, i + BATCH_CHUNK_SIZE);
|
||||
const res = await apiFetch('/api/batch/trash', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ file_ids: chunk, folder_ids: [] })
|
||||
});
|
||||
// 200 = all trashed, 206 = partial; both carry `successful`.
|
||||
if (!res.ok && res.status !== 206) continue;
|
||||
const data = (await res.json().catch(() => ({}))) as Partial<BatchTrashResult>;
|
||||
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
|
||||
for (const id of ok) trashed.add(id);
|
||||
}
|
||||
return trashed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a generated thumbnail blob for a file at a given size. Used by the
|
||||
* photos grid to persist client-generated video frames server-side.
|
||||
*/
|
||||
export async function uploadThumbnail(
|
||||
fileId: string,
|
||||
size: 'icon' | 'preview' | 'large',
|
||||
blob: Blob,
|
||||
contentType = 'image/jpeg'
|
||||
): Promise<void> {
|
||||
await apiFetch(`/api/files/${fileId}/thumbnail/${size}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...getCsrfHeaders(), 'Content-Type': contentType },
|
||||
body: blob
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { User } from '$lib/api/types';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
@@ -20,7 +21,32 @@ export async function updateProfile(patch: ProfilePatch): Promise<User> {
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(patch)
|
||||
});
|
||||
if (!res.ok) throw new Error(`profile update failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
|
||||
// 409 covers two distinct conflicts that share the same status. The
|
||||
// server's audit log carries the structured `reason`; the JSON body
|
||||
// only exposes a human-readable message, so we branch on that.
|
||||
if (res.status === 409) {
|
||||
const msg = (err.message || err.error || '').toLowerCase();
|
||||
const key = msg.includes('already claimed')
|
||||
? 'profile.username_immutable_error'
|
||||
: 'profile.username_taken_error';
|
||||
const fallback = msg.includes('already claimed')
|
||||
? "Your username has already been set and can't be changed."
|
||||
: 'That username is already taken.';
|
||||
throw new Error(t(key, fallback));
|
||||
}
|
||||
// 403 here means the field is governed by the identity provider.
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
t(
|
||||
'profile.edit_oidc_managed',
|
||||
'Your profile is managed by your identity provider. Update it there; changes appear on your next sign-in.'
|
||||
)
|
||||
);
|
||||
}
|
||||
throw new Error(err.message || err.error || `profile update failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as User;
|
||||
}
|
||||
|
||||
@@ -34,7 +60,7 @@ export async function changePassword(currentPw: string, newPw: string): Promise<
|
||||
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function updateAvatar(image: string): Promise<void> {
|
||||
export async function updateAvatar(image: string | null): Promise<void> {
|
||||
const res = await apiFetch('/api/auth/me/image', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
@@ -43,3 +69,55 @@ export async function updateAvatar(image: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`avatar update failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AppPassword {
|
||||
id: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
created_at: string;
|
||||
last_used_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels the server uses for sessions auto-generated when a Nextcloud-style
|
||||
* client authenticates (vs. user-created app passwords). Ported from
|
||||
* `views/profile/profile.js`'s `AUTO_LABELS`.
|
||||
*/
|
||||
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
|
||||
|
||||
/** True when an app password was auto-generated by a client session login. */
|
||||
export function isAutoAppPassword(pw: Pick<AppPassword, 'label'>): boolean {
|
||||
return AUTO_LABELS.includes(pw.label);
|
||||
}
|
||||
|
||||
export async function listAppPasswords(): Promise<AppPassword[]> {
|
||||
const res = await apiFetch('/api/auth/app-passwords', { credentials: 'same-origin' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as AppPassword[] | { app_passwords?: AppPassword[] };
|
||||
return Array.isArray(data) ? data : (data.app_passwords ?? []);
|
||||
}
|
||||
|
||||
/** Returns the one-time generated password (shown once). */
|
||||
export async function createAppPassword(label: string): Promise<string> {
|
||||
const res = await apiFetch('/api/auth/app-passwords', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ label })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
throw new Error(e.error || e.message || `create app password failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { password: string };
|
||||
return data.password;
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(id: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/auth/app-passwords/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error(`revoke app password failed: ${res.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Recipient search for the share People tab — system users (via the system
|
||||
* address book) + groups (via /api/groups/search) + a synthesized "invite by
|
||||
* email" suggestion when the query parses as an email. Ported from the original
|
||||
* shareModal recipient autocomplete (addressBook.searchContacts + _searchGroups
|
||||
* + _looksLikeEmail).
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import type { SubjectType } from './grants';
|
||||
|
||||
export interface Recipient {
|
||||
type: Extract<SubjectType, 'user' | 'group' | 'email'>;
|
||||
/** For email recipients this is the normalised email; for users/groups, the UUID. */
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
full_name?: string;
|
||||
email?: Array<{ email: string; is_primary?: boolean }>;
|
||||
}
|
||||
|
||||
interface GroupResult {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive client-side email check — matches a non-whitespace local part, an
|
||||
* `@`, and a domain with a dot. The server's `normalize_email` is authoritative;
|
||||
* this just decides whether to surface the synthetic invite-by-email row.
|
||||
*/
|
||||
function looksLikeEmail(q: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
|
||||
}
|
||||
|
||||
// The system book lists all users; we filter client-side (matches the original).
|
||||
let contactCache: Contact[] | null = null;
|
||||
/** `false` once we confirm the system address book is unavailable. */
|
||||
let directoryAvailable: boolean | null = null;
|
||||
|
||||
async function systemContacts(): Promise<Contact[]> {
|
||||
if (contactCache) return contactCache;
|
||||
try {
|
||||
const res = await apiFetch('/api/address-books/system/contacts', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) {
|
||||
directoryAvailable = false;
|
||||
contactCache = [];
|
||||
return contactCache;
|
||||
}
|
||||
directoryAvailable = true;
|
||||
contactCache = (await res.json()) as Contact[];
|
||||
} catch {
|
||||
directoryAvailable = false;
|
||||
contactCache = [];
|
||||
}
|
||||
return contactCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the system user directory is reachable. Returns `true` until proven
|
||||
* otherwise so callers degrade gracefully; call `ensureResolvers()` first to
|
||||
* get an accurate answer.
|
||||
*/
|
||||
export function isDirectoryAvailable(): boolean {
|
||||
return directoryAvailable !== false;
|
||||
}
|
||||
|
||||
function contactLabel(c: Contact): { label: string; email: string } {
|
||||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || '';
|
||||
const email = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
|
||||
return { label: name || email || c.id, email };
|
||||
}
|
||||
|
||||
async function searchGroups(q: string): Promise<Recipient[]> {
|
||||
try {
|
||||
const res = await apiFetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const groups = (await res.json()) as GroupResult[];
|
||||
return groups.map((g) => ({ type: 'group' as const, id: g.id, label: g.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Label resolution for existing grants (subject id → display name) ────────
|
||||
let groupCache: Map<string, string> | null = null;
|
||||
|
||||
async function loadGroups(): Promise<Map<string, string>> {
|
||||
if (groupCache) return groupCache;
|
||||
groupCache = new Map();
|
||||
try {
|
||||
const res = await apiFetch('/api/groups/search?q=&limit=200', { credentials: 'same-origin' });
|
||||
if (res.ok) {
|
||||
for (const g of (await res.json()) as GroupResult[]) groupCache.set(g.id, g.name);
|
||||
}
|
||||
} catch {
|
||||
/* leave empty */
|
||||
}
|
||||
return groupCache;
|
||||
}
|
||||
|
||||
/** Preload the user + group caches so grant rows can show names. */
|
||||
export async function ensureResolvers(): Promise<void> {
|
||||
await Promise.all([systemContacts(), loadGroups()]);
|
||||
}
|
||||
|
||||
/** Resolve a subject id to a display label using the preloaded caches. */
|
||||
export function resolveLabel(type: 'user' | 'group', id: string): string {
|
||||
if (type === 'group') return groupCache?.get(id) ?? id;
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
return c ? contactLabel(c).label : id;
|
||||
}
|
||||
|
||||
/** Resolve a subject id to a label + sublabel (email) for member vignettes. */
|
||||
export function resolveRecipient(type: 'user' | 'group', id: string): Recipient {
|
||||
if (type === 'group') {
|
||||
return { type: 'group', id, label: groupCache?.get(id) ?? id };
|
||||
}
|
||||
const c = contactCache?.find((x) => x.id === id);
|
||||
if (!c) return { type: 'user', id, label: id };
|
||||
const { label, email } = contactLabel(c);
|
||||
return { type: 'user', id, label, sublabel: email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined user + group results matching the query (case-insensitive), plus a
|
||||
* synthetic invite-by-email suggestion when the query is an email that no
|
||||
* contact already owns. The current logged-in user is excluded — you can't
|
||||
* share with yourself. Capped at 8 combined (groups, then users, then email).
|
||||
*/
|
||||
export async function searchRecipients(query: string): Promise<Recipient[]> {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return [];
|
||||
const currentUserId = session.user?.id ?? null;
|
||||
const [contacts, groups] = await Promise.all([systemContacts(), searchGroups(q)]);
|
||||
const matched = contacts
|
||||
.filter((c) => c.id !== currentUserId)
|
||||
.map((c) => ({ c, ...contactLabel(c) }))
|
||||
.filter(
|
||||
({ label, email }) => label.toLowerCase().includes(q) || email.toLowerCase().includes(q)
|
||||
);
|
||||
const users: Recipient[] = matched.map(({ c, label, email }) => ({
|
||||
type: 'user' as const,
|
||||
id: c.id,
|
||||
label,
|
||||
sublabel: email
|
||||
}));
|
||||
|
||||
const emailItems: Recipient[] = [];
|
||||
if (looksLikeEmail(q)) {
|
||||
const exists = matched.some(({ email }) => email.toLowerCase() === q);
|
||||
if (!exists) emailItems.push({ type: 'email', id: q, label: q });
|
||||
}
|
||||
|
||||
return [...groups, ...users, ...emailItems].slice(0, 8);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared cursor-pagination helper for the favorites/recent/trash "resources"
|
||||
* endpoints, which all take the same query params. Ported from the legacy
|
||||
* endpoints, which all take the same query params. Ported from the original
|
||||
* favoritesModel/recentModel/trashModel.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Search endpoint — ported from features/files/search.js. */
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import type { SearchResults, SortBy } from '$lib/api/types';
|
||||
|
||||
export interface SearchOptions {
|
||||
folderId?: string;
|
||||
recursive?: boolean;
|
||||
fileTypes?: string[];
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
/** Unix-seconds lower bound on created time. */
|
||||
createdAfter?: number;
|
||||
/** Unix-seconds upper bound on created time. */
|
||||
createdBefore?: number;
|
||||
/** Unix-seconds lower bound on modified time. */
|
||||
modifiedAfter?: number;
|
||||
/** Unix-seconds upper bound on modified time. */
|
||||
modifiedBefore?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: SortBy;
|
||||
}
|
||||
|
||||
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
|
||||
for (const ft of opts.fileTypes ?? []) params.append('type', ft);
|
||||
if (opts.minSize != null) params.append('min_size', String(opts.minSize));
|
||||
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
|
||||
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
|
||||
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
|
||||
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
|
||||
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
|
||||
params.append('limit', String(opts.limit ?? 100));
|
||||
params.append('offset', String(opts.offset ?? 0));
|
||||
params.append('sort_by', opts.sortBy ?? 'relevance');
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, { credentials: 'same-origin' });
|
||||
}
|
||||
|
||||
/** A single autocomplete suggestion returned by the lightweight suggest endpoint. */
|
||||
export interface SearchSuggestions {
|
||||
suggestions: string[];
|
||||
query_time_ms: number;
|
||||
}
|
||||
|
||||
export interface SuggestOptions {
|
||||
folderId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight autocomplete suggestions from the backend `GET /api/search/suggest`
|
||||
* endpoint — name-only hints without the full search overhead.
|
||||
*/
|
||||
export function searchSuggest(
|
||||
query: string,
|
||||
opts: SuggestOptions = {}
|
||||
): Promise<SearchSuggestions> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
if (opts.limit != null) params.append('limit', String(opts.limit));
|
||||
return apiJson<SearchSuggestions>(`/api/search/suggest?${params.toString()}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
|
||||
export async function clearSearchCache(): Promise<void> {
|
||||
const res = await apiFetch('/api/search/cache', {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to clear search cache: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
@@ -31,7 +31,8 @@ export interface ShareListing {
|
||||
export type ShareMetaResult =
|
||||
| { status: 'ok'; data: ShareMeta }
|
||||
| { status: 'password' }
|
||||
| { status: 'expired' };
|
||||
| { status: 'expired' }
|
||||
| { status: 'invalid' };
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
@@ -44,6 +45,8 @@ export async function getShareMeta(token: string): Promise<ShareMetaResult> {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
if (res.status === 410) return { status: 'expired' };
|
||||
// 404 means the token doesn't resolve to any share — a bad/typo'd link.
|
||||
if (res.status === 404) return { status: 'invalid' };
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Public share-link endpoints (/api/shares) — ported from features/sharing. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { ItemType, ShareItem } from '$lib/api/types';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
export interface CreateShareInput {
|
||||
itemId: string;
|
||||
/** Optional human-readable link name (stored as `item_name`). */
|
||||
itemName?: string | null;
|
||||
itemType: ItemType;
|
||||
password?: string | null;
|
||||
/** ISO date string or null; converted to epoch seconds for the wire. */
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export async function createShare(input: CreateShareInput): Promise<ShareItem> {
|
||||
const body = {
|
||||
item_id: input.itemId,
|
||||
item_name: input.itemName ?? null,
|
||||
item_type: input.itemType,
|
||||
password: input.password || null,
|
||||
expires_at: input.expiresAt ? Math.floor(new Date(input.expiresAt).getTime() / 1000) : null
|
||||
};
|
||||
const res = await apiFetch('/api/shares', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(e.error || `create share failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as ShareItem;
|
||||
}
|
||||
|
||||
export async function listSharesForItem(itemId: string, itemType: ItemType): Promise<ShareItem[]> {
|
||||
const params = new URLSearchParams({ item_id: itemId, item_type: itemType });
|
||||
const res = await apiFetch(`/api/shares?${params}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ShareItem[] | { items?: ShareItem[] };
|
||||
return Array.isArray(data) ? data : (data.items ?? []);
|
||||
}
|
||||
|
||||
/** Fetch a single share by its UUID (used to resolve a token's URL on demand). */
|
||||
export async function getShareById(shareId: string): Promise<ShareItem> {
|
||||
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!res.ok) throw new Error(`get share failed: ${res.status}`);
|
||||
return (await res.json()) as ShareItem;
|
||||
}
|
||||
|
||||
export interface UpdateShareInput {
|
||||
/** `null` clears the password; omit to leave it unchanged. */
|
||||
password?: string | null;
|
||||
/** ISO date string clears/sets; converted to epoch seconds. `null` clears. */
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing public link's password and/or expiry.
|
||||
* `PUT /api/shares/{id}` with `{ password, expires_at }`.
|
||||
*/
|
||||
export async function updateShare(shareId: string, input: UpdateShareInput): Promise<ShareItem> {
|
||||
const body: { password?: string | null; expires_at?: number | null } = {};
|
||||
if (input.password !== undefined) body.password = input.password;
|
||||
if (input.expiresAt !== undefined) {
|
||||
body.expires_at = input.expiresAt
|
||||
? Math.floor(new Date(input.expiresAt).getTime() / 1000)
|
||||
: null;
|
||||
}
|
||||
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(e.error || `update share failed: ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as ShareItem;
|
||||
}
|
||||
|
||||
export async function deleteShare(shareId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/shares/${shareId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok && res.status !== 204) throw new Error(`delete share failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a share URL to the clipboard, resolving it against the current origin.
|
||||
* Shared by the dialog and My Shares so copy-link logic lives in one place.
|
||||
* Returns `true` on success.
|
||||
*/
|
||||
export async function copyShareLink(url: string): Promise<boolean> {
|
||||
try {
|
||||
const absolute = typeof location !== 'undefined' ? new URL(url, location.origin).href : url;
|
||||
await navigator.clipboard.writeText(absolute);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Trash endpoints — ported from trashModel.js + views/trash. */
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { fetchResourcePage, type ResourcePage, type ResourcePageOpts } from './resources';
|
||||
import type { TrashResourceItem } from '$lib/api/types';
|
||||
|
||||
@@ -8,6 +9,81 @@ export function fetchTrashPage(opts?: ResourcePageOpts): Promise<ResourcePage<Tr
|
||||
return fetchResourcePage<TrashResourceItem>('/api/trash/resources', 'deletion_date', opts);
|
||||
}
|
||||
|
||||
/** Days from now until `value` (negative when already past). */
|
||||
function daysUntil(value: number | string | Date | null | undefined): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
let date: Date;
|
||||
if (value instanceof Date) date = value;
|
||||
else if (typeof value === 'number') date = new Date(value < 1e12 ? value * 1000 : value);
|
||||
else date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return Math.floor((date.getTime() - Date.now()) / 86_400_000);
|
||||
}
|
||||
|
||||
export type ExpiryTier = 'never' | 'normal' | 'caution' | 'soon' | 'urgent' | 'expired';
|
||||
|
||||
export interface ExpiryChip {
|
||||
tier: ExpiryTier;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiered "remaining lifetime" chip for a trash deletion date — ported from
|
||||
* `formatExpiryChip` in static/js/core/formatters.js. `null` means "Never".
|
||||
*/
|
||||
export function expiryChip(value: number | string | null | undefined): ExpiryChip {
|
||||
if (value === null || value === undefined) {
|
||||
return { tier: 'never', icon: 'infinity', label: t('expiryChip.never', 'Never expires') };
|
||||
}
|
||||
const days = daysUntil(value);
|
||||
if (days === null) {
|
||||
return { tier: 'normal', icon: 'calendar', label: String(value) };
|
||||
}
|
||||
if (days < 0)
|
||||
return {
|
||||
tier: 'expired',
|
||||
icon: 'exclamation-triangle',
|
||||
label: t('expiryChip.expired', 'Expired')
|
||||
};
|
||||
if (days === 0)
|
||||
return { tier: 'urgent', icon: 'clock', label: t('expiryChip.today', 'Expires today') };
|
||||
if (days === 1)
|
||||
return { tier: 'urgent', icon: 'clock', label: t('expiryChip.tomorrow', 'Expires tomorrow') };
|
||||
if (days <= 7)
|
||||
return {
|
||||
tier: 'soon',
|
||||
icon: 'calendar',
|
||||
label: t('expiryChip.inDays', { count: days }, 'Expires in {{count}} days')
|
||||
};
|
||||
if (days <= 30)
|
||||
return {
|
||||
tier: 'caution',
|
||||
icon: 'calendar',
|
||||
label: t('expiryChip.inDays', { count: days }, 'Expires in {{count}} days')
|
||||
};
|
||||
return {
|
||||
tier: 'normal',
|
||||
icon: 'calendar',
|
||||
label: t('expiryChip.onDate', { count: days }, 'Expires in {{count}} days')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse "remaining days" bucket label for the trash group-by swimlanes —
|
||||
* ported from `normalizeExpiryBucket`.
|
||||
*/
|
||||
export function remainingDaysBucket(value: number | string | null | undefined): string {
|
||||
const days = daysUntil(value);
|
||||
if (days === null) return t('expiryBucket.noExpiry', 'No expiration');
|
||||
if (days < 0) return t('expiryBucket.expired', 'Expired');
|
||||
if (days === 0) return t('expiryBucket.today', 'Today');
|
||||
if (days === 1) return t('expiryBucket.tomorrow', 'Tomorrow');
|
||||
if (days <= 7) return t('expiryBucket.week', 'In less than 7 days');
|
||||
if (days <= 30) return t('expiryBucket.month', 'In less than 30 days');
|
||||
return t('expiryBucket.later', 'Later');
|
||||
}
|
||||
|
||||
export async function restoreTrashItem(trashId: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/trash/${trashId}/restore`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* WOPI (Collabora / OnlyOffice) integration — ported from features/files/wopiEditor.js.
|
||||
* `getEditorUrl` returns the iframe action URL + access token; the office editor
|
||||
* is launched by POST-ing the token to that URL (see WopiEditor.svelte).
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
|
||||
export interface WopiEditorData {
|
||||
editor_url: string;
|
||||
access_token: string;
|
||||
access_token_ttl: string | number;
|
||||
}
|
||||
|
||||
const FALLBACK_EXTS = [
|
||||
'docx',
|
||||
'doc',
|
||||
'odt',
|
||||
'rtf',
|
||||
'txt',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'ods',
|
||||
'csv',
|
||||
'pptx',
|
||||
'ppt',
|
||||
'odp'
|
||||
];
|
||||
|
||||
let cachedExts: string[] | null = null;
|
||||
|
||||
export async function getSupportedExtensions(): Promise<string[]> {
|
||||
if (cachedExts) return cachedExts;
|
||||
try {
|
||||
const res = await fetch('/wopi/supported-extensions');
|
||||
if (res.ok) {
|
||||
const exts = (await res.json()) as string[];
|
||||
if (Array.isArray(exts) && exts.length > 0) {
|
||||
cachedExts = exts;
|
||||
return exts;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the hardcoded list */
|
||||
}
|
||||
cachedExts = FALLBACK_EXTS;
|
||||
return cachedExts;
|
||||
}
|
||||
|
||||
export async function canEditWithWopi(filename: string): Promise<boolean> {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
return (await getSupportedExtensions()).includes(ext);
|
||||
}
|
||||
|
||||
export async function getEditorUrl(
|
||||
fileId: string,
|
||||
action: 'edit' | 'view' = 'edit'
|
||||
): Promise<WopiEditorData> {
|
||||
const res = await apiFetch(
|
||||
`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`,
|
||||
{ credentials: 'same-origin' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Editor URL request failed: ${res.status} ${text}`);
|
||||
}
|
||||
return (await res.json()) as WopiEditorData;
|
||||
}
|
||||
|
||||
/** PDFs are view-only in WOPI: an edit request returns 422 → retry as view. */
|
||||
export async function getEditorUrlWithFallback(
|
||||
fileId: string,
|
||||
filename: string,
|
||||
action: 'edit' | 'view' = 'edit'
|
||||
): Promise<WopiEditorData> {
|
||||
try {
|
||||
return await getEditorUrl(fileId, action);
|
||||
} catch (e) {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (action === 'edit' && ext === 'pdf' && msg.includes('422')) {
|
||||
return getEditorUrl(fileId, 'view');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user