visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
+282 -4
View File
@@ -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'
});
}
+121
View File
@@ -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',
+35
View File
@@ -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() || '' });
});
}
+22 -2
View File
@@ -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> {
+100
View File
@@ -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>> {
+30
View File
@@ -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',
+148 -3
View File
@@ -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 {
+72 -12
View File
@@ -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');
}
+68 -3
View File
@@ -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;
}
+76
View File
@@ -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
});
}
+80 -2
View File
@@ -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 -1
View File
@@ -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';
+77
View File
@@ -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}`);
}
+4 -1
View File
@@ -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}`);
}
+110
View File
@@ -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;
}
}
+76
View File
@@ -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',
+85
View File
@@ -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;
}
}