From cf6d87d8058e8f09c97d9cf97c757e8c4df40071 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 18:03:17 -0500 Subject: [PATCH 1/8] fix(thumbnails): resolve blob path instead of logical path for thumbnail generation Since the blob storage migration (3c7c16f), thumbnail generation failed with "No such file or directory" because the handler constructed logical file paths that don't exist on disk. Resolve the actual blob path via get_blob_hash() + dedup_service.blob_path() in both upload and get thumbnail handlers. Add regression tests. --- src/infrastructure/services/mod.rs | 2 + .../services/thumbnail_service_test.rs | 66 +++++++++++++++++++ src/interfaces/api/handlers/file_handler.rs | 33 ++++++++-- 3 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 src/infrastructure/services/thumbnail_service_test.rs diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index fbaef4dd..ca55655e 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -11,6 +11,8 @@ pub mod password_hasher; pub mod path_resolver_service; pub mod path_service; pub mod thumbnail_service; +#[cfg(test)] +mod thumbnail_service_test; pub mod trash_cleanup_service; pub mod webdav_lock_service; pub mod wopi_discovery_service; diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs new file mode 100644 index 00000000..b9e01eec --- /dev/null +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use super::thumbnail_service::{ThumbnailService, ThumbnailSize}; + +/// Minimal valid 1x1 red PNG (68 bytes). +fn tiny_png() -> Vec { + // Generated from a real 1×1 PNG — smallest valid RGBA image. + let mut img = image::RgbaImage::new(1, 1); + img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut buf = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageFormat::Png, + ) + .expect("encode test PNG"); + buf +} + +/// Regression test: thumbnail generation must work when the source file lives +/// at a blob-style path (`.blobs/ab/ab1234…`) rather than a logical path +/// (`folder/image.png`). This broke after the blob storage migration +/// (commit 3c7c16f) because the handler passed the logical path — which +/// doesn't exist on disk — to the thumbnail service. +#[tokio::test] +async fn generate_thumbnail_from_blob_path() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let storage_root = tmp.path(); + + // Simulate a blob-store layout: .blobs/ab/.blob + let blob_dir = storage_root.join(".blobs").join("ab"); + std::fs::create_dir_all(&blob_dir).expect("create blob dir"); + let blob_path = blob_dir.join("ab1234567890.blob"); + std::fs::write(&blob_path, tiny_png()).expect("write test blob"); + + let svc = Arc::new(ThumbnailService::new(storage_root, 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + // The key assertion: the service can read from a blob path (not a logical path) + let result = svc + .get_thumbnail("test-file-id", ThumbnailSize::Icon, &blob_path) + .await; + + let thumb_bytes = result.expect("thumbnail generation should succeed from blob path"); + assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty"); + + // Verify it's valid WebP (starts with "RIFF" magic) + assert!( + thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF", + "output should be WebP format" + ); +} + +/// Verify that a non-existent path produces an error, not a panic. +#[tokio::test] +async fn generate_thumbnail_nonexistent_path_returns_error() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let svc = Arc::new(ThumbnailService::new(tmp.path(), 100, 10 * 1024 * 1024)); + svc.initialize().await.expect("init thumbnail dirs"); + + let bad_path = tmp.path().join("does-not-exist.png"); + let result = svc + .get_thumbnail("missing-id", ThumbnailSize::Icon, &bad_path) + .await; + + assert!(result.is_err(), "should fail for nonexistent file"); +} diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 66784b1b..0f523836 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -14,7 +14,7 @@ use crate::application::ports::file_ports::OptimizedFileContent; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, }; -use crate::application::ports::storage_ports::StorageUsagePort; +use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort}; use crate::application::ports::thumbnail_ports::ThumbnailPort; use crate::common::di::AppState; use crate::interfaces::errors::AppError; @@ -322,8 +322,20 @@ impl FileHandler { .into_response(); } - let storage_root = state.core.path_service.get_root_path(); - let file_path = storage_root.join(&file.path); + // Resolve the actual blob path on disk (not the logical file path). + let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await { + Ok(h) => h, + Err(err) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": format!("File content not found: {}", err) + })), + ) + .into_response(); + } + }; + let file_path = state.core.dedup_service.blob_path(&blob_hash); match thumbnail_service .get_thumbnail(&id, thumb_size.into(), &file_path) @@ -597,12 +609,21 @@ impl FileHandler { .is_supported_image(&file.mime_type) { let file_id = file.id.clone(); - let file_path_rel = file.path.clone(); let thumbnail_service = state.core.thumbnail_service.clone(); - let path_service = state.core.path_service.clone(); + let dedup_service = state.core.dedup_service.clone(); + let file_read = state.repositories.file_read_repository.clone(); tokio::spawn(async move { - let file_path = path_service.get_root_path().join(&file_path_rel); + // Resolve the actual blob path on disk (not the logical file path, + // which doesn't exist when using blob storage). + let blob_hash = match file_read.get_blob_hash(&file_id).await { + Ok(h) => h, + Err(e) => { + tracing::warn!("Skipping thumbnails for {}: {}", file_id, e); + return; + } + }; + let file_path = dedup_service.blob_path(&blob_hash); tracing::info!("🖼️ Generating thumbnails for: {}", file_id); thumbnail_service.generate_all_sizes_background(file_id, file_path); }); From a5e33ac72b8700c641175f998395f074d45f0dc3 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 18:51:39 -0500 Subject: [PATCH 2/8] fix(auth): await logout fetch to prevent token refresh race condition The logout function fired a non-awaited POST /logout then immediately redirected to /login. The login page's session probe would find the cookies still valid and refresh the token, redirecting back to the app. Fix by awaiting the fetch and clearing local state before redirect. --- static/js/app/userMenu.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/static/js/app/userMenu.js b/static/js/app/userMenu.js index c3522b96..5d2fa7b2 100644 --- a/static/js/app/userMenu.js +++ b/static/js/app/userMenu.js @@ -223,17 +223,24 @@ function showUserProfileModal() { }); } -function logout() { +async function logout() { const USER_DATA_KEY = 'oxicloud_user'; - // Tell the server to clear HttpOnly cookies - fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() }) - .catch(() => {}) // Best-effort - .finally(() => { - localStorage.removeItem(USER_DATA_KEY); - sessionStorage.removeItem('redirect_count'); - window.location.href = '/login'; - }); + // Clear local state first to prevent login page from auto-refreshing + localStorage.removeItem(USER_DATA_KEY); + localStorage.removeItem('refresh_attempts'); + sessionStorage.removeItem('redirect_count'); + + // Tell the server to clear HttpOnly cookies (await to ensure cookies are + // cleared before redirecting, otherwise the login page's session probe + // will refresh the token and redirect back to the app). + try { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() }); + } catch (_) { + // Best-effort + } + + window.location.href = '/login'; } window.setupUserMenu = setupUserMenu; From 34e7b9dfa8d5c01ded171082a66861ac35b6e7ff Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 18:53:16 -0500 Subject: [PATCH 3/8] fix(profile): add credentials to fetch calls so auth cookies are sent Profile page showed "Not Authenticated" because fetch calls to /api/auth/me and /api/auth/change-password were missing credentials: 'same-origin', preventing HttpOnly cookies from being sent. --- static/js/views/profile/profile.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index 3cfd6547..95809a8a 100644 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -25,7 +25,7 @@ function timeAgo(dateStr) { async function init() { try { - const resp = await fetch(API + '/auth/me', { headers: headers() }); + const resp = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { showError(); return; } const user = await resp.json(); @@ -66,7 +66,7 @@ async function init() { } try { - const oidcResp = await fetch(API + '/auth/oidc/providers'); + const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' }); if (oidcResp.ok) { const oidcInfo = await oidcResp.json(); if (!oidcInfo.password_login_enabled) { @@ -114,6 +114,7 @@ async function changePassword(e) { const resp = await fetch(API + '/auth/change-password', { method: 'PUT', headers: headers(), + credentials: 'same-origin', body: JSON.stringify({ current_password: currentPw, new_password: newPw }) }); From 4293a30d5023e0feb00f94c5b364bb9744100e4a Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 20:27:43 -0500 Subject: [PATCH 4/8] fix(setup): use /api/setup endpoint for admin creation The admin setup form was calling /api/auth/register which creates a regular user (role is hardcoded to User) and never sets the system_initialized flag. Switch to /api/setup which creates an actual admin and marks the system as initialized. Add setup token input field. --- static/js/features/auth/auth.js | 22 +++++++++++++++++++--- static/login.html | 13 +++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/static/js/features/auth/auth.js b/static/js/features/auth/auth.js index c6982468..d52b223e 100644 --- a/static/js/features/auth/auth.js +++ b/static/js/features/auth/auth.js @@ -729,9 +729,25 @@ if (isLoginPage && adminSetupForm) { } try { - // Register admin account - const data = await register('admin', email, password, 'admin'); - + // Use the /api/setup endpoint which creates an admin and marks the system as initialized + const setupToken = document.getElementById('admin-setup-token').value; + const response = await fetch('/api/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + credentials: 'same-origin', + body: JSON.stringify({ + username: 'admin', + email, + password, + setup_token: setupToken + }) + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.message || 'Setup failed'); + } + const data = await response.json(); + // Show success message in the GUI instead of alert const successMsg = window.i18n ? window.i18n.t('auth.admin_success') : 'Admin account created successfully! You can now log in.'; diff --git a/static/login.html b/static/login.html index 936947a2..76e2ba97 100644 --- a/static/login.html +++ b/static/login.html @@ -273,6 +273,19 @@ > +
+ + + Check the server console output for the setup token. +
+ From 6db4e0753807801e47387ffca2a3e8d20406506c Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 21:35:18 -0500 Subject: [PATCH 5/8] fix(auth): apply auth middleware to /me, /change-password, /logout and add credentials to admin.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protected auth routes (/me, /change-password, /logout) were merged with public routes in auth_handler.rs but never had auth middleware applied in main.rs — so the CurrentUserId extractor always failed with 401. Split auth_routes() into auth_public_routes() and auth_protected_routes(), applying auth + CSRF middleware to the latter. Also added credentials: 'same-origin' to all 13 fetch calls in admin.js so the browser sends HttpOnly auth cookies with requests. --- src/interfaces/api/handlers/auth_handler.rs | 21 +++++++-------- src/main.rs | 29 +++++++++++++-------- static/js/views/admin/admin.js | 26 +++++++++--------- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b6ace6a7..37c386c5 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -16,25 +16,24 @@ use crate::interfaces::api::cookie_auth; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUserId; -pub fn auth_routes() -> Router> { - // Routes that do NOT require authentication - let public_routes = Router::new() +/// Public auth routes — no authentication required. +pub fn auth_public_routes() -> Router> { + Router::new() .route("/status", get(get_system_status)) // OIDC endpoints (all public) .route("/oidc/providers", get(oidc_providers)) .route("/oidc/authorize", get(oidc_authorize)) .route("/oidc/callback", get(oidc_callback)) - .route("/oidc/exchange", post(oidc_exchange)); + .route("/oidc/exchange", post(oidc_exchange)) +} - // Routes that DO require authentication - we use route_layer to apply middleware - // The middleware will use the state passed with .with_state() from main.rs - let protected_routes = Router::new() +/// Protected auth routes — require authentication (auth + CSRF middleware +/// must be applied by the caller in main.rs). +pub fn auth_protected_routes() -> Router> { + Router::new() .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) - .route("/logout", post(logout)); - - // Combine public and protected routes - public_routes.merge(protected_routes) + .route("/logout", post(logout)) } /// Rate-limited auth routes — split out so main.rs can apply per-endpoint diff --git a/src/main.rs b/src/main.rs index 90a39c4a..15796d87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -67,10 +66,7 @@ async fn main() -> Result<(), Box> { if !storage_path.exists() { std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory"); } - let locales_path = PathBuf::from("./static/locales"); - if !locales_path.exists() { - std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); - } + // Locales are embedded in the binary via rust-embed — no filesystem path needed. // Initialize database pools if auth is enabled let db_pools = if config.features.enable_auth { @@ -94,7 +90,7 @@ async fn main() -> Result<(), Box> { }; // Build all services via the factory - let factory = AppServiceFactory::with_config(storage_path, locales_path, config.clone()); + let factory = AppServiceFactory::with_config(storage_path, None, config.clone()); let app_state = factory.build_app_state(db_pools).await .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)"); @@ -171,7 +167,8 @@ async fn main() -> Result<(), Box> { } if config.features.enable_auth { use interfaces::api::handlers::auth_handler::{ - auth_routes, login_route, refresh_route, register_route, setup_route, + auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route, + setup_route, }; use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::api::handlers::device_auth_handler; @@ -227,8 +224,16 @@ async fn main() -> Result<(), Box> { rate_limit_refresh, )) .with_state(app_state.clone()); - // Remaining auth routes (status, OIDC, protected /me, /logout, etc.) - let auth_router = auth_routes().with_state(app_state.clone()); + // Public auth routes (status, OIDC) + let auth_public = auth_public_routes().with_state(app_state.clone()); + // Protected auth routes (/me, /change-password, /logout) — require auth + CSRF + let auth_protected = auth_protected_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -286,8 +291,10 @@ async fn main() -> Result<(), Box> { .nest("/api/auth", auth_login) .nest("/api/auth", auth_register) .nest("/api/auth", auth_refresh) - // Other auth endpoints (status, OIDC, protected /me, /logout) - .nest("/api/auth", auth_router) + // Public auth endpoints (status, OIDC) + .nest("/api/auth", auth_public) + // Protected auth endpoints (/me, /change-password, /logout) + .nest("/api/auth", auth_protected) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router) // Device Auth Grant public endpoints (authorize + token polling) diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index ff5a02ba..677e299a 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -65,7 +65,7 @@ function switchTab(name, el) { async function loadDashboard() { try { - const resp = await fetch(API + '/admin/dashboard', { headers: headers() }); + const resp = await fetch(API + '/admin/dashboard', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) return; const d = await resp.json(); document.getElementById('ds-total-users').textContent = d.total_users; @@ -103,7 +103,7 @@ async function loadUsers() { const tbody = document.getElementById('users-tbody'); tbody.innerHTML = ' Loading…'; try { - const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers() }); + const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { tbody.innerHTML = ' Failed to load users'; return; } const data = await resp.json(); totalUsers = data.total; @@ -151,7 +151,7 @@ async function toggleRole(userId, currentRole) { if (!confirm('Change role to ' + newRole + '?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/role', { - method: 'PUT', headers: headers(), body: JSON.stringify({ role: newRole }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ role: newRole }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } @@ -162,7 +162,7 @@ async function toggleActive(userId, currentActive) { if (!confirm('Are you sure you want to ' + action + ' this user?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/active', { - method: 'PUT', headers: headers(), body: JSON.stringify({ active: !currentActive }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ active: !currentActive }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } @@ -171,7 +171,7 @@ async function toggleActive(userId, currentActive) { async function deleteUser(userId, username) { if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return; try { - const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers() }); + const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } @@ -193,7 +193,7 @@ async function saveQuota() { const bytes = Math.round(val * unit); try { const resp = await fetch(API + '/admin/users/' + quotaUserId + '/quota', { - method: 'PUT', headers: headers(), body: JSON.stringify({ quota_bytes: bytes }) + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ quota_bytes: bytes }) }); if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } @@ -231,7 +231,7 @@ async function submitCreateUser() { btn.disabled = true; btn.innerHTML = ' Creating…'; try { const resp = await fetch(API + '/admin/users', { - method: 'POST', headers: headers(), + method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ username, password, email, role, quota_bytes: quotaBytes }) }); if (resp.ok) { @@ -271,7 +271,7 @@ async function submitResetPassword() { btn.disabled = true; btn.innerHTML = ' Resetting…'; try { const resp = await fetch(API + '/admin/users/' + resetPwUserId + '/password', { - method: 'PUT', headers: headers(), + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ new_password: password }) }); if (resp.ok) { closeResetPasswordModal(); } @@ -285,7 +285,7 @@ async function toggleRegistration(enabled) { else showElement('registration-warning', 'flex'); try { const resp = await fetch(API + '/admin/settings/registration', { - method: 'PUT', headers: headers(), + method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ registration_enabled: enabled }) }); if (!resp.ok) { @@ -330,7 +330,7 @@ async function testConnection() { btn.disabled = true; btn.innerHTML = ' Discovering…'; const resultDiv = document.getElementById('discovery-result'); try { - const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), body: JSON.stringify({ issuer_url: url }) }); + const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ issuer_url: url }) }); const r = await resp.json(); if (r.success) { resultDiv.innerHTML = '
' + escapeHtml(r.message) + '
Issuer
' + escapeHtml(r.issuer||'—') + '
Auth Endpoint
' + escapeHtml(r.authorization_endpoint||'—') + '
'; @@ -357,7 +357,7 @@ async function saveOidcSettings() { provider_name: document.getElementById('provider-name').value.trim() || null, }; try { - const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), body: JSON.stringify(body) }); + const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) }); if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); } else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); } } catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); } @@ -366,13 +366,13 @@ async function saveOidcSettings() { async function init() { try { - const me = await fetch(API + '/auth/me', { headers: headers() }); + const me = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' }); if (!me.ok) { showAccessDenied(); return; } const user = await me.json(); if (user.role !== 'admin') { showAccessDenied(); return; } currentAdminId = user.id; - const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() }); + const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers(), credentials: 'same-origin' }); if (oidcResp.ok) { const s = await oidcResp.json(); document.getElementById('oidc-enabled').checked = s.enabled; From 2a75e837525021e69b612c0e3413ac081ff68d40 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 21:44:02 -0500 Subject: [PATCH 6/8] fix(thumbnails): display thumbnails in file grid/list and fix WOPI intercepting all file opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wopiEditor.canEdit() is async but was called without await, so the returned Promise was always truthy — routing every file click to the WOPI editor (which 404'd). Made openFile() async and added await in both ui.js and inlineViewer.js. Added thumbnail elements to _createFileCard() and _createFileItem() for image files, loading from /api/files/{id}/thumbnail/icon with lazy loading and error fallback. --- static/css/components/cards.css | 11 +++++++++++ static/css/components/fileList.css | 3 +++ static/js/app/ui.js | 6 ++++-- static/js/features/files/inlineViewer.js | 4 ++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/static/css/components/cards.css b/static/css/components/cards.css index 681d1acd..45761d14 100644 --- a/static/css/components/cards.css +++ b/static/css/components/cards.css @@ -322,6 +322,17 @@ button.favorite-star { overflow: hidden; } +/* Thumbnail image inside file-icon (grid and list views) */ +.file-icon .file-thumb { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 1; +} + .file-icon.image-icon::before { content: ""; position: absolute; diff --git a/static/css/components/fileList.css b/static/css/components/fileList.css index 88b6548a..4dfb4ceb 100644 --- a/static/css/components/fileList.css +++ b/static/css/components/fileList.css @@ -145,6 +145,9 @@ .file-item .file-icon.image-icon { background-color: #e0f2fe; + position: relative; + overflow: hidden; + border-radius: 4px; } .file-item .file-icon.image-icon i, diff --git a/static/js/app/ui.js b/static/js/app/ui.js index a649e56a..24501261 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -747,13 +747,13 @@ const ui = { return null; }; - const openFile = (file) => { + const openFile = async (file) => { if (!file) return; if (window.recent) { document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } })); } // WOPI editor intercept: open Office documents in the WOPI editor - if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) { window.wopiEditor.openInModal(file.id, file.name, 'edit'); return; } @@ -1139,6 +1139,7 @@ const ui = {
+ ${iconSpecialClass === 'image-icon' ? `` : ''}
${escapeHtml(file.name)}
@@ -1176,6 +1177,7 @@ const ui = {
+ ${iconSpecialClass === 'image-icon' ? `` : ''}
${escapeHtml(file.name)} diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index 610f7af6..b792f888 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -88,11 +88,11 @@ class InlineViewer { console.log('Inline viewer initialized'); } - openFile(file) { + async openFile(file) { console.log('Opening file:', file); // WOPI editor intercept: open Office documents in the WOPI editor - if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) { + if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) { window.wopiEditor.openInModal(file.id, file.name, 'edit'); return; } From f2bd2203072a9ec0d94636b21c58e182d186732e Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Wed, 4 Mar 2026 21:55:06 -0500 Subject: [PATCH 7/8] fix(admin): support HttpOnly cookie auth in admin_guard admin_guard() only checked Authorization: Bearer header, ignoring the oxicloud_access HttpOnly cookie used by browser sessions. All admin endpoints (dashboard, OIDC settings, etc.) returned 401 for logged-in users. Fall back to cookie_auth::extract_cookie_value() when no Bearer token is present. --- src/interfaces/api/handlers/admin_handler.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 1e4d1679..f230e793 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -50,12 +50,18 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, S let token = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) + .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string())) + .or_else(|| { + crate::interfaces::api::cookie_auth::extract_cookie_value( + headers, + crate::interfaces::api::cookie_auth::ACCESS_COOKIE, + ) + }) .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; let claims = auth .token_service - .validate_token(token) + .validate_token(&token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; if claims.role != "admin" { From aa96eecc7911e15f780f89cd7774c961882b92ed Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 12:53:15 -0500 Subject: [PATCH 8/8] fix: restore locales_path after removing embed-assets commit The embed-assets commit made locales_path optional, and main.rs was updated to pass None. Removing that commit restored the PathBuf signature but left main.rs passing None. Restore the original locales_path initialization. --- src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 15796d87..cb6fbf5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -89,8 +90,14 @@ async fn main() -> Result<(), Box> { None }; + // Ensure locales directory exists for i18n + let locales_path = PathBuf::from("./static/locales"); + if !locales_path.exists() { + std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory"); + } + // Build all services via the factory - let factory = AppServiceFactory::with_config(storage_path, None, config.clone()); + let factory = AppServiceFactory::with_config(storage_path, locales_path, config.clone()); let app_state = factory.build_app_state(db_pools).await .expect("Failed to build application state. If running in Docker, ensure the storage volume is writable by the oxicloud user (UID 1001)");