diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 29f80ff7..3aa84f38 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -22,6 +22,53 @@ async function mutate(url: string, method: string, body?: unknown): Promise(url: string): Promise { + const res = await apiFetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() } + }); + if (!res.ok) { + const e = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(e.message || `POST ${url} failed: ${res.status}`); + } + return (await res.json()) as T; +} + +// ── Maintenance ─────────────────────────────────────────────────────────── + +/** Outcome of a bulk metadata re-extraction run. */ +export interface ReextractResult { + message: string; + total: number; + processed: number; + failed: number; +} + +/** Re-scan every audio file and backfill its tag metadata (idempotent). */ +export function reextractAudioMetadata(): Promise { + return postJson('/api/admin/audio/metadata/reextract'); +} + +/** Backfill EXIF / container capture dates for all media, re-bucketing the + * Photos timeline by real capture date (idempotent). */ +export function reextractPhotoMetadata(): Promise { + return postJson('/api/admin/photos/metadata/reextract'); +} + +/** A freshly generated AES-256 at-rest blob-encryption key (base64) plus a + * data-loss warning authored by the server. */ +export interface GeneratedKey { + key: string; + warning: string; +} + +/** Generate a random AES-256 key for at-rest blob encryption. */ +export function generateEncryptionKey(): Promise { + return postJson('/api/admin/settings/storage/generate-key'); +} + // ── Users ─────────────────────────────────────────────────────────────── export interface AdminUsersPage { diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index f07f0800..ea9337e4 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -338,11 +338,11 @@ /* Expand the grid track as soon as at least one owner cell is visible. */ .files-list-view:has(.owner-cell:not(.hidden)) { - --files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 72px; + --files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 200px; } .files-list-view { - --files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px; + --files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 200px; display: flex; flex-direction: column; width: 100%; diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 5f3f7aea..219684f7 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -5,6 +5,7 @@ createUser, deletePlugin, deleteUser, + generateEncryptionKey, getDashboard, getMigration, getOidcSettings, @@ -16,6 +17,8 @@ listPlugins, listUsers, migrationAction, + reextractAudioMetadata, + reextractPhotoMetadata, resetUserPassword, saveOidc, savePluginRetention, @@ -30,6 +33,7 @@ testStorage, verifyMigration, type AdminDashboard, + type GeneratedKey, type MigrationStatus, type MigrationVerifyResult, type OidcSettings, @@ -37,6 +41,7 @@ type PluginInfo, type PluginLogEntry, type PluginRetention, + type ReextractResult, type SmtpInfo, type SmtpTestResult, type StorageSettings, @@ -629,6 +634,51 @@ errorToast(e); } + // ── Maintenance: bulk metadata re-extraction ───────────────────────────── + let audioBusy = $state(false); + let audioResult = $state(null); + let photoBusy = $state(false); + let photoResult = $state(null); + + async function runAudioReindex() { + audioBusy = true; + audioResult = null; + try { + audioResult = await reextractAudioMetadata(); + } catch (e) { + reportError(e); + } finally { + audioBusy = false; + } + } + + async function runPhotoReindex() { + photoBusy = true; + photoResult = null; + try { + photoResult = await reextractPhotoMetadata(); + } catch (e) { + reportError(e); + } finally { + photoBusy = false; + } + } + + // ── Storage: generate an at-rest encryption key ────────────────────────── + let keyBusy = $state(false); + let generatedKey = $state(null); + + async function runGenerateKey() { + keyBusy = true; + try { + generatedKey = await generateEncryptionKey(); + } catch (e) { + reportError(e); + } finally { + keyBusy = false; + } + } + async function copyText(text: string) { try { await navigator.clipboard.writeText(text); @@ -997,6 +1047,58 @@ {/if} {/if} + +
+

{t('admin.maintenance', 'Maintenance')}

+

+ {t( + 'admin.maintenance_hint', + 'Re-scan existing files to backfill metadata. Safe to re-run; processes the whole library and may take a while.' + )} +

+
+ + {#if audioResult} + + {t( + 'admin.reextract_done', + { + processed: audioResult.processed, + total: audioResult.total, + failed: audioResult.failed + }, + '{{processed}}/{{total}} processed · {{failed}} failed' + )} + + {/if} +
+
+ + {#if photoResult} + + {t( + 'admin.reextract_done', + { + processed: photoResult.processed, + total: photoResult.total, + failed: photoResult.failed + }, + '{{processed}}/{{total}} processed · {{failed}} failed' + )} + + {/if} +
+
{/if} {:else if tab === 'oidc'}
@@ -1377,6 +1479,40 @@ {/if} {/if}
+ +
+

{t('admin.encryption', 'Encryption')}

+

+ {t( + 'admin.encryption_hint', + 'Generate an AES-256 key for at-rest blob encryption, then set it as OXICLOUD_STORAGE_ENCRYPTION_KEY in your server environment.' + )} +

+ + {#if generatedKey} +

+ {generatedKey.key} + +

+

+ + {t( + 'admin.gen_key_warning', + 'Store this key securely. If it is lost, the encrypted data is irrecoverably lost.' + )} +

+ {/if} +
{:else if tab === 'smtp'}

{t('admin.smtp_status', 'SMTP status')}

@@ -1990,7 +2126,7 @@ } .log-level--error { - color: var(--color-danger-text); + color: var(--color-error-text); } .log-level--warn { @@ -2067,7 +2203,7 @@ } .ds-fill--danger { - background: var(--color-danger-text); + background: var(--color-error-text); } .kv { @@ -2171,8 +2307,8 @@ } .warn-card--danger { - border-color: var(--color-danger-text); - color: var(--color-danger-text); + border-color: var(--color-error-text); + color: var(--color-error-text); } /* Per-user storage-usage progress bar in the users table. */ @@ -2200,7 +2336,7 @@ } .quota-fill--danger { - background: var(--color-danger-text); + background: var(--color-error-text); } .quota-input { @@ -2232,7 +2368,7 @@ } .icon-btn--danger { - color: var(--color-danger-text); + color: var(--color-error-text); } .icon-btn--success { @@ -2282,8 +2418,8 @@ } .discovery-result--fail { - border-color: var(--color-danger-text); - color: var(--color-danger-text); + border-color: var(--color-error-text); + color: var(--color-error-text); } .discovery-result .kv { @@ -2298,6 +2434,18 @@ flex-wrap: wrap; } + .maint-row { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + margin-top: var(--space-3); + } + + .maint-result { + font-variant-numeric: tabular-nums; + } + .install-bar { display: flex; align-items: center; @@ -2499,7 +2647,7 @@ } .status--error { - color: var(--color-danger-text); + color: var(--color-error-text); } .link-btn { @@ -2511,6 +2659,6 @@ } .link-btn--danger { - color: var(--color-danger-text); + color: var(--color-error-text); } diff --git a/frontend/src/routes/photos/+page.svelte b/frontend/src/routes/photos/+page.svelte index ec4ad05c..3f9ddc83 100644 --- a/frontend/src/routes/photos/+page.svelte +++ b/frontend/src/routes/photos/+page.svelte @@ -287,6 +287,18 @@ } } + /** + * A tile thumbnail failed to load (no server thumbnail — e.g. SVGs, which the + * backend can't rasterise — or a transient error). Hide the broken so + * the always-present placeholder shows through, and for videos kick off + * client-side frame extraction (which, on success, re-renders the tile from + * `videoThumbs`). + */ + function onThumbError(e: Event, photo: PhotoItem) { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + if (isVideo(photo)) void generateVideoThumb(photo); + } + // ── Client-side video thumbnail generation ────────────────────────────── // When the server has no thumbnail for a video tile the errors; we // then extract a frame with the browser's native decoder and upload it. @@ -522,6 +534,10 @@ {#snippet tile(photo: PhotoItem, sizeStyle?: string)}