From 650d388b4e8fa3d897122dc1bc6c52b4928efe10 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Tue, 30 Jun 2026 20:11:29 +0200 Subject: [PATCH 01/12] style: apply rustfmt and remove stale Dockerfile COPY Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 46bfa69a..21cb9e57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -105,7 +105,6 @@ FROM base AS builder-cache WORKDIR /app COPY Cargo.toml Cargo.lock build.rs ./ COPY src src -COPY static static COPY migrations migrations COPY templates templates COPY --from=frontend /static-dist ./static-dist From f220217659e437c3d6968eba4143d7a99e581bc8 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 19:59:16 +0200 Subject: [PATCH 02/12] feat(nc-webdav): generic dead-props on PROPPATCH/PROPFIND/REPORT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NC PROPPATCH only special-cased oc:favorite, silently dropped every other prop while claiming 200. PROPFIND had no dead-prop lookup at all. Reuse native /webdav/ dead-prop plumbing (now pub(crate)) so NC surface gets real RFC 4918 §4.2 storage. Missing resource on PROPPATCH is now 404 instead of a fake success no-op. --- src/application/adapters/webdav_adapter.rs | 7 +- src/interfaces/api/handlers/webdav_handler.rs | 13 +- src/interfaces/nextcloud/report_handler.rs | 21 +- src/interfaces/nextcloud/webdav_handler.rs | 254 +++++++++--------- 4 files changed, 159 insertions(+), 136 deletions(-) diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 9cd063b1..79071376 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -419,7 +419,12 @@ impl WebDavAdapter { /// /// Written AFTER the live-property propstats inside a ``. /// Only emitted when `dead_props` is non-empty. - fn write_dead_props_propstat( + /// + /// `pub(crate)` so the NextCloud-compatible handler + /// (`interfaces::nextcloud::webdav_handler`) can append the same + /// dead-property block to its own bespoke PROPFIND writers instead + /// of duplicating this XML shape. + pub(crate) fn write_dead_props_propstat( xml_writer: &mut Writer, dead_props: &[(QualifiedName, Option)], ) -> Result<()> { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index b612ffe0..62e82929 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -1092,7 +1092,11 @@ async fn resolve_or_legacy( /// the dead-prop lookup is broken; surfacing a 500 here would mask the /// resource entirely from sync clients. The legacy path-keyed lookup /// behaved the same way (`.unwrap_or_default()`); we preserve it. -async fn file_dead_props( +/// +/// `pub(crate)` — also reused by the NextCloud-compatible PROPFIND +/// handler (`interfaces::nextcloud::webdav_handler`), which needs the +/// same lenient fetch for its own response writers. +pub(crate) async fn file_dead_props( state: &Arc, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { @@ -1107,8 +1111,9 @@ async fn file_dead_props( } /// Same shape as `file_dead_props` but for folder rows. Used by the -/// streaming PROPFIND walker. -async fn folder_dead_props( +/// streaming PROPFIND walker (and, via `pub(crate)`, by the NextCloud +/// handler's own streaming walker). +pub(crate) async fn folder_dead_props( store: &DeadPropertyStore, folder: &FolderDto, ) -> Vec<(QualifiedName, Option)> { @@ -1124,7 +1129,7 @@ async fn folder_dead_props( /// File-leaf variant for the streaming walker (takes a `&DeadPropertyStore` /// rather than the full `&Arc` so it can be called from inside /// the async-stream future without cloning state). -async fn streamed_file_dead_props( +pub(crate) async fn streamed_file_dead_props( store: &DeadPropertyStore, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index a380d6c9..39f22d1d 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::domain::entities::file::File; +use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props}; use crate::interfaces::errors::AppError; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response, @@ -159,14 +160,15 @@ async fn handle_filter_files( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -176,14 +178,15 @@ async fn handle_filter_files( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -270,14 +273,15 @@ async fn handle_search( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -288,14 +292,15 @@ async fn handle_search( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 410f0ed1..964f26a0 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter}; +use crate::application::adapters::webdav_adapter::{ + PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, +}; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{ @@ -23,7 +25,10 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; -use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; +use crate::infrastructure::services::webdav_dead_property_store::ResourceRef; +use crate::interfaces::api::handlers::webdav_handler::{ + PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props, +}; use crate::interfaces::errors::AppError; use crate::interfaces::range_requests::{not_modified_response, range_response}; use crate::interfaces::upload_ingest::ingest_body_to_cas; @@ -277,6 +282,7 @@ async fn handle_propfind( let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); + let dead_props = file_dead_props(&state, &file).await; let mut buf = Vec::new(); write_nc_file_multistatus( @@ -286,7 +292,7 @@ async fn handle_propfind( &user.username, subpath, file_id_svc, - &favorite_ids, + (&favorite_ids, &dead_props), ) .await .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; @@ -455,6 +461,12 @@ async fn handle_head( // ──────────────────── PROPPATCH ──────────────────── +/// The `oc:favorite` element is live server state routed through the +/// favorites service, not a dead property — every other +/// namespace/local-name pair PROPPATCH sends is stored verbatim via +/// `DeadPropertyStore`. +const OC_FAVORITE_NS: &str = "http://owncloud.org/ns"; + async fn handle_proppatch( state: Arc, req: Request, @@ -468,150 +480,120 @@ async fn handle_proppatch( .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; - let body_str = String::from_utf8_lossy(&body_bytes); - - // Resolve the target resource once — needed for two things: - // 1. Applying the oc:favorite mutation when the PROPPATCH body - // carries one (`item_type` distinguishes file vs folder rows - // in the favorites table). - // 2. Picking the right `` shape in the multi-status + // Resolve the target resource — needed for three things: + // 1. The dead-property store key is the resource id (folder_id + // XOR file_id), so we need a `ResourceRef`. + // 2. Applying the oc:favorite mutation (`item_type` distinguishes + // file vs folder rows in the favorites table). + // 3. Picking the right `` shape in the multi-status // response: collection (folder) hrefs MUST end in `/` per // RFC 4918 §5.2 — see `nc_collection_href` for the full - // reasoning. Without this distinction the NC desktop client - // parser aborted on PROPFIND; PROPPATCH would hit the same - // wall the moment the user favourited a folder. + // reasoning. // - // When the resource is missing we tolerate it for the no-op - // PROPPATCH path (no favorite directive in the body) — matches - // the prior behaviour. A PROPPATCH that *does* try to set - // favorite on a missing resource still returns NotFound. + // A missing resource is now always a 404: unlike the previous + // favorite-only implementation (which merely re-declared success + // without doing anything), this handler performs real writes, so + // silently no-opping on a nonexistent path would be a foot-gun — + // matches the native `/webdav/` handler's contract. let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; - let resource = if let Ok(file) = file_service + let (resource_ref, item_id, item_type, is_collection) = if let Ok(file) = file_service .get_file_by_path(&internal_path, chroot.drive_id) .await { - Some((file.id, "file")) + let id = Uuid::parse_str(&file.id) + .map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?; + (ResourceRef::File(id), file.id, "file", false) } else if let Ok(folder) = folder_service .get_folder_by_path(&internal_path, chroot.drive_id) .await { - Some((folder.id, "folder")) + let id = Uuid::parse_str(&folder.id) + .map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?; + (ResourceRef::Folder(id), folder.id, "folder", true) } else { - None + return Err(AppError::not_found("Resource not found")); }; - let is_collection = matches!(resource, Some((_, "folder"))); - // Parse oc:favorite value from PROPPATCH XML. - let favorite_value = parse_proppatch_favorite(&body_str); + let ops = WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; - if let Some(value) = favorite_value { - let Some((item_id, item_type)) = resource else { - return Err(AppError::not_found("Resource not found")); - }; - - if let Some(fav_svc) = state.favorites_service.as_ref() { - if value == 1 { - fav_svc - .add_to_favorites(user.id, &item_id, item_type) + let dead_props = &state.webdav_dead_props; + let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); + for op in &ops { + let is_favorite = + |name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite"; + match op { + PropPatchOp::Set(pv) if is_favorite(&pv.name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + if pv.value.as_deref().map(str::trim) == Some("1") { + fav_svc + .add_to_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to add favorite: {e}")) + })?; + } else { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + } + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) if is_favorite(name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + results.push((name, true)); + } + PropPatchOp::Set(pv) => { + dead_props + .set(resource_ref, pv.name.clone(), pv.value.clone()) .await .map_err(|e| { - AppError::internal_error(format!("Failed to add favorite: {}", e)) - })?; - } else { - fav_svc - .remove_from_favorites(user.id, &item_id, item_type) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to remove favorite: {}", e)) + AppError::internal_error(format!("Failed to store dead property: {e}")) })?; + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) => { + dead_props.remove(resource_ref, name).await.map_err(|e| { + AppError::internal_error(format!("Failed to remove dead property: {e}")) + })?; + results.push((name, true)); } } } - // Return 207 Multi-Status with success response using quick_xml - // for safe escaping. Collection vs file href chosen by resource - // type to satisfy the RFC 4918 §5.2 trailing-slash invariant — - // see the comment block at the top of this function. + // Collection vs file href chosen by resource type to satisfy the + // RFC 4918 §5.2 trailing-slash invariant — see the comment block + // at the top of this function. let href = if is_collection { nc_collection_href(url_user, subpath) } else { nc_href(url_user, subpath) }; - let mut buf = Vec::new(); - { - let mut xml = Writer::new(&mut buf); - xml.write_event(Event::Text(BytesText::new( - "", - ))) - .map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?; - - let mut ms = BytesStart::new("d:multistatus"); - ms.push_attribute(("xmlns:d", "DAV:")); - ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns")); - xml.write_event(Event::Start(ms)) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - - xml.write_event(Event::Start(BytesStart::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:href", &href) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Empty(BytesStart::new("oc:favorite"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK") - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - } + let mut response_body = Vec::new(); + WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( + |e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)), + )?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(buf)) + .body(Body::from(response_body)) .unwrap()) } -/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml. -fn parse_proppatch_favorite(body: &str) -> Option { - use quick_xml::Reader; - - let mut reader = Reader::from_str(body); - let mut inside_favorite = false; - - loop { - match reader.read_event() { - Ok(Event::Start(ref e)) => { - let local = e.local_name(); - if local.as_ref() == b"favorite" { - inside_favorite = true; - } - } - Ok(Event::Text(ref e)) if inside_favorite => { - let text = e.decode().ok()?; - return text.trim().parse::().ok(); - } - Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => { - inside_favorite = false; - } - Ok(Event::Eof) => break, - Err(_) => break, - _ => {} - } - } - None -} - // ──────────────────── PUT ──────────────────── /// Strip the optional `W/` weak prefix and surrounding double-quotes @@ -1225,6 +1207,10 @@ fn write_nc_multistatus_open(xml: &mut Writer) -> Result<( /// Generate the multistatus XML for a single-file PROPFIND. The folder /// case streams via [`build_nc_streaming_propfind`] instead. +/// +/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource +/// decorations fetched by the caller — to stay under clippy's +/// argument-count lint. async fn write_nc_file_multistatus( writer: W, file: &FileDto, @@ -1232,8 +1218,9 @@ async fn write_nc_file_multistatus( username: &str, subpath: &str, file_id_svc: Option<&Arc>, - favorite_ids: &HashSet, + extras: (&HashSet, &[(QualifiedName, Option)]), ) -> Result<(), String> { + let (favorite_ids, dead_props) = extras; let (file_id_map, _) = batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await; @@ -1252,10 +1239,10 @@ async fn write_nc_file_multistatus( &mut xml, file, &href, - file_id, - oc_id.as_deref(), + (file_id, oc_id.as_deref()), username, favorite_ids, + dead_props, )?; xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) @@ -1298,6 +1285,7 @@ fn build_nc_streaming_propfind( }; let (_, folder_id_map) = batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await; + let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await; let mut buf = Vec::with_capacity(4096); { @@ -1306,7 +1294,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &subpath); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs) + write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, &folder_dead) .map_err(std::io::Error::other)?; } yield Bytes::from(buf); @@ -1335,11 +1323,15 @@ fn build_nc_streaming_propfind( }; let file_uuids: Vec = batch.iter().map(|f| f.id.clone()).collect(); let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await; + let mut file_deads = Vec::with_capacity(batch_len); + for file in &batch { + file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await); + } let mut chunk = Vec::with_capacity(batch_len * 1024); { let mut xml = Writer::new(&mut chunk); - for file in &batch { + for (file, dead) in batch.iter().zip(file_deads.iter()) { let child_sub = if subpath.is_empty() { file.name.clone() } else { @@ -1348,7 +1340,7 @@ fn build_nc_streaming_propfind( let href = nc_href(&username, &child_sub); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_file_response(&mut xml, file, &href, fid, oc_id.as_deref(), &username, &favs) + write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1384,11 +1376,15 @@ fn build_nc_streaming_propfind( }; let folder_uuids: Vec = result.items.iter().map(|sf| sf.id.clone()).collect(); let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await; + let mut sub_deads = Vec::with_capacity(result.items.len()); + for sf in &result.items { + sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await); + } let mut chunk = Vec::with_capacity(result.items.len() * 1024); { let mut xml = Writer::new(&mut chunk); - for sf in &result.items { + for (sf, dead) in result.items.iter().zip(sub_deads.iter()) { let child_sub = if subpath.is_empty() { sf.name.clone() } else { @@ -1397,7 +1393,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &child_sub); let fid = sub_id_map.get(&sf.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs) + write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1432,15 +1428,20 @@ fn build_nc_streaming_propfind( .unwrap() } +/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed +/// together (`oc_id` is derived from `file_id`) — to stay under +/// clippy's argument-count lint now that `dead_props` is also threaded +/// through. pub fn write_folder_response( xml: &mut Writer, folder: &FolderDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1511,21 +1512,26 @@ pub fn write_folder_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?; Ok(()) } +/// See `write_folder_response` for why `(file_id, oc_id)` are bundled +/// into `oc_ids`. pub fn write_file_response( xml: &mut Writer, file: &FileDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1600,6 +1606,8 @@ pub fn write_file_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?; From 94e01458552d111cd0fc67cbadda3ed359279e55 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 19:59:23 +0200 Subject: [PATCH 03/12] test(webdav): cover NC dead-props PROPPATCH/PROPFIND contract Round-trip, upsert, remove, 404 on missing resource, survives MOVE, reaped on DELETE, oc:favorite regression guard, folder coverage. --- tests/api/nc_webdav_dead_properties.hurl | 470 +++++++++++++++++++++++ tests/api/run.sh | 1 + 2 files changed, 471 insertions(+) create mode 100644 tests/api/nc_webdav_dead_properties.hurl diff --git a/tests/api/nc_webdav_dead_properties.hurl b/tests/api/nc_webdav_dead_properties.hurl new file mode 100644 index 00000000..e2923874 --- /dev/null +++ b/tests/api/nc_webdav_dead_properties.hurl @@ -0,0 +1,470 @@ +# ============================================================= +# OxiCloud — NextCloud WebDAV: dead-properties (RFC 4918 §4.2) +# ============================================================= +# `tests/api/webdav_dead_properties.hurl` covers the native +# `/webdav/` surface end-to-end. This file covers the same +# PROPPATCH/PROPFIND contract on the NextCloud-compatible surface +# (`/remote.php/dav/files/{user}/...`), which — until now — had NO +# generic dead-property support: PROPPATCH only special-cased +# `oc:favorite` via an ad hoc XML scan and silently discarded any +# other property while still claiming `200 OK`; PROPFIND always +# emitted a fixed hardcoded property set with no dead-property +# lookup at all. A client (or litmus) PROPPATCHing a custom label +# through the NextCloud mount got a false success and then never +# saw the property again. +# +# Coverage: +# 1. Setup: JWT login, mint an NC app password. +# 2. PUT a probe file via the NC DAV surface. +# 3. PROPPATCH set a custom property → 207. +# 4. PROPFIND → value round-trips verbatim. +# 5. PROPPATCH upsert (same name, new value) → PROPFIND confirms +# overwrite, not a duplicate row. +# 6. PROPPATCH remove → PROPFIND confirms absence. +# 7. PROPPATCH on a nonexistent resource → 404 (the tightened +# contract: PROPPATCH now does real work, so a previous +# "always claim success" no-op on a missing resource would be +# a foot-gun, not a feature). +# 8. Re-set a property, MOVE the file → PROPFIND on the new path +# still returns it (resource id is stable across MOVE). +# 9. DELETE, then PUT a fresh file at the same path → PROPFIND +# does NOT see the old marker (new resource, no leaked state). +# 10. Regression guard: `oc:favorite` PROPPATCH/PROPFIND still +# works, unaffected by the refactor from the ad hoc favorite +# scanner to generic `WebDavAdapter::parse_proppatch`. +# 11. Folder coverage: MKCOL, PROPPATCH a dead property on the +# folder, PROPFIND confirms it, cleanup. +# +# XPath assertions use `local-name()` so the test is robust against +# the server's chosen namespace prefix for dead properties (`X:`). +# +# NOTE: in Hurl, [BasicAuth] must be the LAST section before the +# blank-line/body — any request headers go above it, not below. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — JWT login, then mint an NC app password (NC DAV uses +# Basic Auth, not the JWT bearer token). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_webdav_dead_properties" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a probe file through the NC DAV surface. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +hello nc dead properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PROPPATCH set a custom (dead) property. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + hello-nc-dead-property + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND confirms the round-trip. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "hello-nc-dead-property" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Upsert: setting the same name again overwrites rather +# than duplicating (ON CONFLICT DO UPDATE at the store). +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + updated-nc-value + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "updated-nc-value" +xpath "count(//*[local-name()='testlabel'])" == 1 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Remove the property; PROPFIND confirms absence. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='testlabel'])" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPPATCH against a nonexistent resource → 404. +# Prior behaviour on this handler silently no-opped +# (and still claimed success) when the body carried no +# `oc:favorite` directive; now that PROPPATCH performs +# real dead-property writes, a missing resource must be +# a hard failure, matching the native `/webdav/` handler. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-does-not-exist.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + should-not-be-stored + + + +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Re-set a marker, MOVE the file, confirm the property +# followed the resource (id-stable across MOVE — no +# store-side rename bookkeeping needed). +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + survives-nc-move + + + +``` + +HTTP 207 + + +MOVE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Destination: {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +# Fresh destination → 201 (RFC 4918 §9.9.4). +HTTP 201 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "survives-nc-move" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — DELETE, then PUT a fresh file at the same path: the +# old marker must NOT resurface (new resource, no leaked +# dead-property state). Whether DELETE soft-deletes to +# trash or hard-deletes, the recreated path resolves to +# a brand-new resource id with no dead-property rows of +# its own. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +fresh file at the same nc path +``` + +HTTP 201 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='testlabel'])" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Regression guard: `oc:favorite` still works after the +# PROPPATCH handler was rewritten from an ad hoc +# favorite-only scanner to generic dead-property +# handling with an `oc:favorite` special case. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 1 + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "1" + + +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 0 + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "0" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — probe file. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Folder coverage: MKCOL, PROPPATCH, PROPFIND, cleanup. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 201 + + +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + nc-folder-keeps-this + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='foldermark'])" == "nc-folder-keeps-this" + + +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Teardown — revoke the app password minted in Step 1. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} + +HTTP 200 diff --git a/tests/api/run.sh b/tests/api/run.sh index 6fa42e63..472272c5 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -168,6 +168,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ + "$API_DIR/nc_webdav_dead_properties.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" From 0ad0ea1a437b759b2fe67f805893f6d53269751d Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 22:54:48 +0200 Subject: [PATCH 04/12] fix(webdav): reject PROPPATCH on protected DAV:/oc:/nc:/ocs: props DeadPropertyStore let PROPPATCH set any namespace/name verbatim, incl. names the server itself emits as live state (DAV: entirely, plus oc:/nc:/ocs: names used by write_file_response / write_folder_response). That either forges a live prop or stores dead rows nothing ever reads. is_protected_property() denylists them; both PROPPATCH handlers (native + NC) now return per-property 403 instead of storing. oc:favorite stays writable via its existing special-case, which runs before the protection check. --- src/application/adapters/webdav_adapter.rs | 40 +++++++++++++++++++ src/interfaces/api/handlers/webdav_handler.rs | 8 +++- src/interfaces/nextcloud/webdav_handler.rs | 8 +++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 79071376..a29c682b 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -72,6 +72,46 @@ impl std::fmt::Display for QualifiedName { } } +/// Whether PROPPATCH must refuse to set/remove this property as a dead +/// property (RFC 4918 §9.2 — server MAY reject a PROPPATCH attempt on a +/// live property; DeadPropertyStore has no business holding a value that +/// PROPFIND / REPORT already emit from live server state). +pub fn is_protected_property(qn: &QualifiedName) -> bool { + match qn.namespace.as_str() { + // RFC 4918 §15 — the DAV: namespace is server-owned in its + // entirety. Any PROPPATCH into it either forges a live + // property (dual-emission) or accumulates unread garbage + // (silent litter). + "DAV:" => true, + + // Every name below appears verbatim in write_folder_response + // / write_file_response in the NC handler. Adding a new + // live emitter → add its name here. + "http://owncloud.org/ns" => matches!( + qn.name.as_str(), + "favorite" + | "fileid" + | "id" + | "owner-id" + | "owner-display-name" + | "permissions" + | "share-types" + | "size" + ), + + "http://nextcloud.org/ns" => matches!( + qn.name.as_str(), + "has-preview" | "is-encrypted" | "mount-type" | "creation_time" | "upload_time" + ), + + "http://open-collaboration-services.org/ns" => { + matches!(qn.name.as_str(), "share-permissions") + } + + _ => false, + } +} + /// PROPFIND request type #[derive(Debug, PartialEq)] pub enum PropFindType { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 62e82929..b32c9f58 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -18,7 +18,7 @@ use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, + LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -807,6 +807,12 @@ async fn handle_proppatch( let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); for op in &ops { match op { + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } PropPatchOp::Set(pv) => { dead_props .set(resource_ref, pv.name.clone(), pv.value.clone()) diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 964f26a0..26fc379c 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, + PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::favorites_ports::FavoritesUseCase; @@ -556,6 +556,12 @@ async fn handle_proppatch( } results.push((name, true)); } + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } PropPatchOp::Set(pv) => { dead_props .set(resource_ref, pv.name.clone(), pv.value.clone()) From 3501857a70ef9e2d4edbb2008b6953f88970e5e9 Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Wed, 1 Jul 2026 22:54:56 +0200 Subject: [PATCH 05/12] test(webdav): cover protected-property PROPPATCH rejection Native + NC surfaces: DAV: displayname/getetag, oc:fileid/ permissions, nc:has-preview all 403 and never land in the store; mixed request shows per-property granularity (protected prop 403 alongside an ordinary custom prop 200); oc:favorite regression guard confirms its special-case still works despite being on the protected list. --- tests/api/run.sh | 1 + tests/api/webdav_protected_properties.hurl | 368 +++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 tests/api/webdav_protected_properties.hurl diff --git a/tests/api/run.sh b/tests/api/run.sh index 472272c5..57a4b8bc 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -169,6 +169,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ "$API_DIR/nc_webdav_dead_properties.hurl" \ + "$API_DIR/webdav_protected_properties.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/webdav_protected_properties.hurl b/tests/api/webdav_protected_properties.hurl new file mode 100644 index 00000000..66219024 --- /dev/null +++ b/tests/api/webdav_protected_properties.hurl @@ -0,0 +1,368 @@ +# ============================================================= +# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15) +# ============================================================= +# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name +# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could +# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names +# the server ALSO emits as live state in PROPFIND/REPORT responses +# (see `write_file_response` / `write_folder_response` in the NC +# handler and the native PROPFIND writer). That produces either a +# forged live property (the server would need to pick which of two +# values to emit) or a silently stored, never-read row. +# +# `is_protected_property()` (src/application/adapters/webdav_adapter.rs) +# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs: +# names the server actually emits elsewhere. Both PROPPATCH handlers +# (native `/webdav/` and NC `/remote.php/dav/`) consult it before +# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's +# per-property `403 Forbidden` inside the 207 multi-status — not a +# blanket request failure, and not a silent no-op success. +# +# Coverage: +# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV: +# namespace) → 207 envelope, inner 403 for that property. +# 2. PROPFIND confirms the live displayname is unchanged — the +# forged value never landed anywhere. +# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403 +# contract on the Remove path, not just Set. +# 4. Native /webdav/: an oc:-namespaced protected name +# (`oc:fileid`) is blocked even on the surface that doesn't +# normally speak NextCloud namespaces — protection is +# namespace-global, not surface-scoped. +# 5. Mixed request: one protected DAV: prop + one ordinary custom +# dead property in the SAME PROPPATCH → 207 with both a 403 +# propstat block and a 200 propstat block; the custom property +# DOES get stored (per-property granularity, not all-or-nothing +# rejection). +# 6. NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`) → 403; PROPFIND confirms it was never +# written to the dead-property store. +# 7. NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# 8. Regression guard: `oc:favorite` is on the protected list too +# (it's live state the NC handler emits), but the handler's +# favorite special-case runs BEFORE the protected-property +# check, so toggling favorite through PROPPATCH still works — +# protection must not swallow the one oc: name that's +# legitimately client-writable via a side channel. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT; mint an NC app password for the +# NC-surface half of this file (NC DAV uses Basic Auth). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{token}} +Content-Type: application/json +{ "label": "webdav_protected_properties" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a probe file via native WebDAV. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +hello protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PROPPATCH set on DAV:displayname (live property) must +# be rejected with a per-property 403, not silently +# accepted into DeadPropertyStore. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged-name + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND confirms the live displayname is untouched. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract +# on the Remove path. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Protection is namespace-global: an oc:-namespaced +# protected name is blocked even on the native /webdav/ +# surface, which doesn't otherwise speak NextCloud +# namespaces. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + should-not-be-stored + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Mixed request: one protected DAV: prop + one ordinary +# custom dead property in the SAME PROPPATCH → per- +# property granularity, not all-or-nothing rejection. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged + allowed-alongside-protected + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='propstat'])" == 2 +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403" +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — native probe file. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — NC surface: PUT a probe file via the NC DAV mount. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +hello nc protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`, not the specially-handled favorite) +# → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='permissions'])" != "forged" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Regression guard: oc:favorite is on the protected +# list too, but the handler's favorite special-case +# runs before the protection check, so toggling it via +# PROPPATCH must still work end to end. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 1 + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "1" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — NC probe file, teardown app password. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{token}} + +HTTP 200 From a3801f5836dd3830a0f7feb80f6839a6ba0a2c82 Mon Sep 17 00:00:00 2001 From: moduvoice Date: Sat, 11 Jul 2026 23:10:35 +0700 Subject: [PATCH 06/12] i18n: complete Korean (ko) locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ko.json locale existed but was missing 377 of 1392 keys (~27%), covering entire feature areas added since the initial translation: admin plugin management, storage/OIDC/SMTP settings, photos, music playlists, device pairing, search filters, share dialogs, and more. Filled in all missing keys with natural Korean translations matching the existing tone and terminology in the file (파일/폴더/공유/업로드 등), verified full key parity with en.json (1392/1392) and matching {{placeholder}} interpolation tokens on every translated string. --- frontend/static/locales/ko.json | 451 ++++++++++++++++++++++++++++++-- 1 file changed, 424 insertions(+), 27 deletions(-) diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 4ae4f0c9..088794ff 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -53,7 +53,9 @@ "sharedwithme": "나와 공유됨", "profile": "프로필", "shared_with_me": "나와 공유됨", - "groups": "그룹" + "groups": "그룹", + "primary": "기본", + "toggle": "탐색 메뉴 전환" }, "photos": { "empty_state": "아직 사진이 없습니다", @@ -62,7 +64,21 @@ "view_daily": "일", "view_monthly": "월", "view_yearly": "년", - "group_by": "그룹화 기준" + "group_by": "그룹화 기준", + "confirm_delete": "사진 {{n}}장을 휴지통으로 이동하시겠습니까?", + "confirm_delete_one": "{{name}}을(를) 삭제하시겠습니까?", + "delete": "사진 삭제", + "empty": "아직 사진이 없습니다.", + "full_resolution": "원본 해상도", + "trash_partial": "{{total}}개 중 {{ok}}개가 휴지통으로 이동되었습니다.", + "trashed": "{{n}}개가 휴지통으로 이동되었습니다.", + "layout_square": "그리드", + "layout_justified": "맞춤형", + "tab_moments": "순간", + "tab_places": "장소", + "tab_people": "인물", + "map_loading": "지도 로딩 중…", + "map_error": "지도를 불러올 수 없습니다" }, "music": { "create_playlist": "재생목록 만들기", @@ -130,7 +146,26 @@ "share_with_user": "User ID or email", "toggle_public": "Visibility", "track_removed": "Track removed", - "prev": "이전" + "prev": "이전", + "add_selected": "선택 항목 추가", + "create_playlist_hint": "재생목록 이름을 입력하여 새로 만드세요.", + "created": "\"{{name}}\"이(가) 생성되었습니다.", + "delete_playlist": "재생목록 삭제", + "deleted": "\"{{name}}\"이(가) 삭제되었습니다.", + "edit_description": "설명 편집", + "empty_playlist": "이 재생목록에는 아직 트랙이 없습니다.", + "new_playlist": "새 재생목록", + "no_audio": "오디오 파일을 찾을 수 없습니다.", + "now_private": "재생목록이 비공개로 전환되었습니다.", + "now_public": "재생목록이 공개로 전환되었습니다.", + "pick_or_create": "기존 목록: {{list}}. 추가하거나 새로 만들려면 이름을 입력하세요.", + "rename_playlist": "재생목록 이름 변경", + "reordered": "재생목록 순서가 변경되었습니다.", + "seek": "탐색", + "selected_count": "{{n}}개 선택됨", + "share_added": "공유되었습니다.", + "track_count": "트랙 {{n}}개", + "tracks_added": "트랙 {{n}}개가 추가되었습니다." }, "actions": { "search": "파일 검색...", @@ -181,7 +216,9 @@ "auto": "시스템과 동일" }, "manage_groups": "그룹 관리", - "admin": "관리자" + "admin": "관리자", + "mit_license": "MIT 라이선스", + "title": "사용자 메뉴" }, "share": { "dialogTitle": "공유 링크", @@ -232,7 +269,40 @@ "link_name": "Link name (optional)", "notifyByEmail": "이메일로 알림", "revoke": "Remove", - "role_label": "역할" + "role_label": "역할", + "addPassword": "비밀번호 추가", + "add_people": "사용자, 그룹 또는 이메일 추가…", + "bad_password": "비밀번호가 올바르지 않습니다. 다시 시도해 주세요.", + "changePassword": "비밀번호 변경", + "create_link": "링크 생성", + "created": "공개 링크가 생성되었습니다", + "dialog_title": "\"{{name}}\" 공유", + "download_zip": "ZIP 다운로드", + "empty_folder": "이 폴더가 비어 있습니다.", + "error": "문제가 발생했습니다. 다시 시도해 주세요.", + "expired": "이 공유 링크는 더 이상 사용할 수 없습니다.", + "expires_optional": "만료일 (선택 사항)", + "expiry": "만료일", + "invalid": "이 공유 링크가 유효하지 않습니다.", + "link": "링크", + "no_people": "아직 아무와도 공유되지 않았습니다.", + "none": "아직 공개 링크가 없습니다.", + "notify": { + "coalesced": "{{n}}명은 최근에 이미 알림을 받았습니다.", + "rateLimited": "{{n}}명이 속도 제한에 걸렸습니다 — 나중에 다시 시도하세요.", + "sent": "{{n}}명에게 이메일로 알렸습니다.", + "skipped": "{{n}}명 건너뜀 (이메일 없음 / 수신 거부)." + }, + "passwordPrompt": "비밀번호 설정:", + "passwordPrompt_clear": "새 비밀번호 (제거하려면 비워두세요):", + "password_cleared": "비밀번호가 제거되었습니다", + "password_optional": "비밀번호 (선택 사항)", + "password_set": "비밀번호가 변경되었습니다", + "password_title": "비밀번호 필요", + "public_link": "공개 링크", + "set_expiry": "만료일 설정", + "title": "공유됨", + "unlock": "잠금 해제" }, "share_dialogTitle": "공유 링크", "share_linkLabel": "공유 링크:", @@ -365,7 +435,55 @@ "folder": "폴더", "new_folder": "새 폴더", "share": "공유", - "view": "보기" + "view": "보기", + "already_favorites": "선택한 항목이 모두 이미 즐겨찾기에 있습니다", + "batch_delete": "선택 항목 삭제", + "breadcrumb": "경로", + "cancel_selection": "선택 취소", + "col_modified": "날짜", + "col_path": "위치", + "confirm_batch_delete": "{{n}}개 항목을 휴지통으로 이동하시겠습니까?", + "confirm_delete": "\"{{name}}\"을(를) 휴지통으로 이동하시겠습니까?", + "confirm_delete_n": "{{count}}개 항목을 삭제하시겠습니까?", + "copied": "복사됨", + "copy_here": "여기에 복사", + "copy_n": "{{n}}개 항목 복사", + "copy_title": "\"{{name}}\" 복사", + "download_zip": "ZIP으로 다운로드", + "edit_new_tab": "새 탭에서 편집", + "editor": "문서 편집기", + "empty_title": "이 폴더가 비어 있습니다", + "favorite": "즐겨찾기 추가", + "favorited": "즐겨찾기됨", + "grid": "그리드", + "list": "목록", + "more_actions": "추가 작업", + "move": "이동", + "move_here": "여기로 이동", + "move_n": "{{n}}개 항목 이동", + "move_title": "\"{{name}}\" 이동", + "moved": "이동됨", + "new_folder_prompt": "새 폴더 이름", + "no_home": "홈 폴더를 사용할 수 없습니다.", + "no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.", + "no_subfolders": "하위 폴더가 없습니다.", + "open": "열기", + "open_parent": "상위 폴더 열기", + "owner_me": "나", + "preview_failed": "미리보기를 불러올 수 없습니다.", + "select_all": "전체 선택", + "selected_count": "{{count}}개 선택됨", + "selection": "선택", + "shared": "공유됨", + "unfavorite": "즐겨찾기 해제", + "uploaded": "업로드 완료", + "uploaded_saved": "업로드 완료 — {{mb}}MB 중복 제거됨", + "uploaded_partial": "{{ok}}개 업로드됨, {{failed}}개 실패", + "uploaded_skipped": "{{ok}}개 업로드됨 · {{skipped}}개 건너뜀 (일반 파일 아님)", + "upload_failed": "업로드 실패", + "uploading": "업로드 중…", + "uploading_file": "{{name}} 업로드 중…", + "uploading_n": "파일 업로드 중 {{done}}/{{total}}…" }, "dialogs": { "rename_folder": "폴더 이름 변경", @@ -431,7 +549,8 @@ "group_depth_exceeded": "중첩 깊이가 허용 최대값(8)을 초과합니다.", "group_virtual_immutable": "«Internal» 그룹은 시스템이 관리하며 수정할 수 없습니다.", "group_not_found": "그룹을 찾을 수 없습니다.", - "group_name_taken": "이 이름의 그룹이 이미 존재합니다." + "group_name_taken": "이 이름의 그룹이 이미 존재합니다.", + "forbidden": "파일을 불러올 수 없습니다" }, "breadcrumb": { "home": "홈" @@ -451,7 +570,10 @@ "trashed_time": "삭제 시간" }, "delete": "영구 삭제", - "empty_action": "휴지통 비우기" + "empty_action": "휴지통 비우기", + "confirm_delete": "이 항목을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.", + "confirm_empty": "휴지통을 비우시겠습니까? 되돌릴 수 없습니다.", + "restored": "복원됨" }, "daysRemaining": { "expired": "만료됨", @@ -519,7 +641,17 @@ "magic_hint": "비밀번호가 없으신가요? 이메일을 입력하시면 일회용 로그인 링크를 보내드립니다.", "magic_unavailable": "이 서버에서는 이메일 로그인을 사용할 수 없습니다.", "passwords_match": "Passwords match", - "sign_in": "로그인" + "sign_in": "로그인", + "cookie_rejected": "로그인은 성공했지만 브라우저가 세션 쿠키를 거부했습니다. HTTP를 사용 중이라면 OXICLOUD_COOKIE_SECURE=false로 설정하거나 HTTPS를 사용하세요.", + "login_error": "로그인 오류", + "magic_error": "문제가 발생했습니다. 다시 시도해 주세요.", + "magic_prompt": "비밀번호가 없으신가요? 이메일 링크로 로그인하세요", + "magic_send": "링크 보내기", + "magic_sent": "해당 계정이 존재하면 로그인 링크가 전송되었습니다. 받은편지함을 확인하세요.", + "register_error": "가입 실패", + "session_expired": "세션이 만료되었습니다. 다시 로그인해 주세요.", + "signing_in": "로그인 중…", + "toggle_password": "비밀번호 표시" }, "storage": { "title": "저장소", @@ -531,7 +663,8 @@ "download_file": "파일 다운로드", "zoom_in": "확대", "zoom_out": "축소", - "zoom_reset": "줌 초기화" + "zoom_reset": "줌 초기화", + "zoom": "확대/축소" }, "language_selector": { "title": "환영합니다!", @@ -570,7 +703,8 @@ "accessed": "접근일", "empty_state": "최근 파일이 없습니다", "empty_hint": "열어본 파일이 여기에 표시됩니다", - "loadMore": "더 불러오기" + "loadMore": "더 불러오기", + "confirm_clear": "최근 항목을 지우시겠습니까?" }, "notifications": { "file_renamed": "파일 이름이 변경되었습니다", @@ -587,7 +721,8 @@ "link_created": "링크 생성됨", "share_success": "공유 링크가 성공적으로 생성되었습니다", "upload_files_section_title": "여기서는 업로드할 수 없습니다", - "upload_files_section_body": "파일을 업로드하려면 파일 섹션으로 이동하세요" + "upload_files_section_body": "파일을 업로드하려면 파일 섹션으로 이동하세요", + "clear": "모두 지우기" }, "batch": { "one_selected": "1개 선택됨", @@ -826,7 +961,132 @@ "include_in_music_index": "음악에 포함", "include_in_music_index_help": "이 Drive의 오디오 파일을 음악 라이브러리에 포함합니다. 기본 개인 Drive는 자동으로 포함됩니다. 실제로 음악 컬렉션이 있는 공유 Drive에서 켜세요 (예: 「가족 음악」, 「밴드 협업」).", "implied_by_forbid_sharing": "이미 「리소스별 공유 금지」에 의해 적용됨." - } + }, + "tab_plugins": "플러그인", + "plugins_title": "플러그인", + "plugins_disabled": "이 서버에서는 플러그인이 비활성화되어 있습니다. WASM 플러그인을 여기서 관리하려면 OXICLOUD_ENABLE_PLUGINS=true로 설정하고 \"plugins\" 기능을 활성화하여 빌드하세요.", + "plugins_install_title": "플러그인 설치", + "plugins_install_intro": "plugin.toml과 컴파일된 WebAssembly 모듈(.wasm)이 포함된 플러그인 번들(.zip)을 업로드하세요. 설치 전에 매니페스트가 검증되고 모듈이 점검됩니다.", + "plugins_bundle_label": "플러그인 번들 (.zip)", + "plugins_install": "플러그인 설치", + "plugins_installed_title": "설치된 플러그인", + "plugins_col_name": "이름", + "plugins_col_id": "ID", + "plugins_col_version": "버전", + "plugins_col_events": "이벤트", + "plugins_col_status": "상태", + "plugins_col_actions": "작업", + "plugins_loading": "플러그인 로딩 중…", + "plugins_none": "설치된 플러그인이 없습니다.", + "plugins_enabled": "활성화됨", + "plugins_disabled_badge": "비활성화됨", + "plugins_enable": "활성화", + "plugins_disable": "비활성화", + "plugins_delete": "삭제", + "plugins_confirm_delete": "플러그인 \"{{name}}\"을(를) 삭제하시겠습니까? 서버에서 관련 파일이 제거됩니다.", + "plugins_installing": "설치 중…", + "plugins_installed": "{{name}}이(가) 설치되었습니다.", + "plugins_install_missing_bundle": "플러그인 번들(.zip)을 선택하세요.", + "plugins_details": "로그 및 세부 정보", + "plugins_back": "플러그인으로 돌아가기", + "plugins_retention_title": "로그 보관 기간", + "plugins_retention_intro": "보관 기간을 초과했거나 크기 상한을 넘은 로테이션된 로그 조각은 예약된 일정에 따라 정리됩니다.", + "plugins_retention_days": "보관 기간(일)", + "plugins_retention_max_mb": "최대 로그 크기(MB)", + "plugins_retention_save": "보관 설정 저장", + "plugins_retention_saved": "보관 설정이 저장되었습니다.", + "plugins_retention_invalid": "0 이상의 숫자를 입력하세요.", + "plugins_logs_title": "로그", + "plugins_logs_level_all": "모든 레벨", + "plugins_logs_search": "메시지 검색…", + "plugins_logs_live": "실시간", + "plugins_logs_clear": "지우기", + "plugins_logs_confirm_clear": "이 플러그인의 모든 로그를 지우시겠습니까?", + "plugins_logs_none": "로그 항목이 없습니다.", + "plugins_logs_col_time": "시간", + "plugins_logs_col_level": "레벨", + "plugins_logs_col_kind": "종류", + "plugins_logs_col_invocation": "호출", + "plugins_logs_col_message": "메시지", + "plugins_logs_showing": "{{total}}개 중 {{from}}–{{to}} 표시", + "auth": "인증", + "available": "사용 가능", + "confirm_delete_plugin": "플러그인 {{name}}을(를) 삭제하시겠습니까?", + "disable": "비활성화", + "email_auto": "비워두면 자동으로 생성됩니다", + "enable": "활성화", + "env_locked": "환경 변수로 설정됨", + "last_login": "마지막 로그인", + "logs_all": "모든 레벨", + "logs_empty": "로그 항목이 없습니다.", + "logs_invocation": "호출", + "logs_kind": "종류", + "logs_level": "레벨", + "logs_live": "실시간", + "logs_message": "메시지", + "logs_search": "검색…", + "logs_showing": "{{total}}개 중 {{from}}–{{to}} 표시", + "logs_time": "시간", + "mig_eta": "약 {{min}}분 남음", + "mig_failed": "실패한 블롭 {{n}}개", + "mig_start": "시작", + "mig_verify": "무결성 확인", + "mig_verify_mismatch": "크기 불일치 {{n}}건", + "mig_verify_missing": "누락 {{n}}건", + "mig_verify_summary": "{{checked}}개 확인됨, 데이터베이스 총 {{total}}개", + "migration": "스토리지 마이그레이션", + "new_password": "새 비밀번호", + "no_plugins": "설치된 플러그인이 없습니다.", + "oidc": "OIDC / SSO", + "oidc_admin_groups": "관리자 그룹", + "oidc_auth_endpoint": "인증 엔드포인트", + "oidc_client_secret": "클라이언트 시크릿", + "oidc_discover": "테스트 / 검색", + "oidc_enabled": "OIDC 로그인 활성화", + "oidc_provider_name": "제공자 이름", + "oidc_secret_set": "클라이언트 시크릿이 이미 구성되어 있습니다.", + "over_80": "할당량 80% 초과 사용자 {{n}}명", + "over_quota": "할당량 초과 사용자 {{n}}명", + "password_reset": "비밀번호 재설정", + "plugin": "플러그인", + "plugin_logs": "플러그인 로그", + "plugins": "플러그인", + "plugins_clear_logs": "로그 지우기", + "plugins_install_hint": "플러그인 번들(.zip)을 업로드하세요.", + "plugins_retention": "로그 보관 기간", + "plugins_retention_max": "최대 크기(MB)", + "plugins_upload": ".zip 업로드", + "quota": "스토리지 사용량", + "quota_for": "할당량 대상", + "registration": "회원가입", + "registration_disabled_warning": "공개 회원가입이 비활성화되어 있습니다. 관리자만 새 계정을 만들 수 있습니다.", + "settings_saved_ok": "설정이 저장되었습니다.", + "smtp": "이메일 (SMTP)", + "smtp_from": "보내는 사람", + "smtp_host": "호스트", + "smtp_port": "포트", + "smtp_status": "SMTP 상태", + "smtp_to": "recipient@example.com", + "storage_blobs": "블롭", + "storage_current": "현재 백엔드", + "storage_dedup": "중복 제거 비율", + "storage_preset": "프리셋", + "storage_size": "저장됨", + "storage_test": "연결 테스트", + "time_day_ago": "{{n}}일 전", + "time_hour_ago": "{{n}}시간 전", + "time_just_now": "방금", + "unchanged": "현재 값을 유지하려면 비워두세요", + "running": "실행 중…", + "maintenance": "유지 관리", + "maintenance_hint": "기존 파일을 다시 스캔하여 메타데이터를 채웁니다. 여러 번 실행해도 안전하며, 전체 라이브러리를 처리하므로 시간이 걸릴 수 있습니다.", + "reextract_audio": "오디오 메타데이터 다시 추출", + "reextract_photos": "사진 및 동영상 촬영 날짜 다시 추출", + "reextract_done": "{{processed}}/{{total}} 처리됨 · 실패 {{failed}}건", + "encryption": "암호화", + "encryption_hint": "저장 데이터(blob) 암호화를 위한 AES-256 키를 생성한 뒤, 서버 환경 변수 OXICLOUD_STORAGE_ENCRYPTION_KEY에 설정하세요.", + "gen_key": "키 생성", + "gen_key_warning": "이 키를 안전하게 보관하세요. 분실 시 암호화된 데이터를 복구할 수 없습니다." }, "profile": { "page_title": "프로필", @@ -914,12 +1174,20 @@ "photo_save_failed": "Failed to save photo", "photo_no_file": "Please select a file first", "photo_managed_by_oidc": "Photo managed by your identity provider.", - "password_mismatch": "비밀번호가 일치하지 않습니다" + "password_mismatch": "비밀번호가 일치하지 않습니다", + "app_pw_revoke": "앱 비밀번호 취소", + "avatar": "아바타", + "copied": "복사됨", + "copy_failed": "복사할 수 없습니다", + "language": "언어", + "language_auto": "자동", + "saved": "프로필이 저장되었습니다" }, "upload": { "uploading": "업로드 중...", "files": "파일", - "complete": "{{count}} / {{total}} 업로드됨" + "complete": "{{count}} / {{total}} 업로드됨", + "files_counter": "파일 {{completed}}/{{total}}" }, "storage_quota_exceeded": "저장 공간 할당량 초과", "sharedwithme": { @@ -988,7 +1256,9 @@ "virtual_internal_explanation": "이 서버의 모든 내부 사용자", "create": "그룹 생성", "empty": "아직 그룹이 없습니다.", - "members": "구성원" + "members": "구성원", + "add_member_search": "추가할 사용자 또는 그룹 검색…", + "nested": "그룹" }, "myshares": { "copyLink": "링크 복사", @@ -999,11 +1269,18 @@ "notifyRateLimited": "이 수신자에게 알림이 너무 많습니다 — 나중에 다시 시도하세요.", "removeAccess": "액세스 제거", "resendInvitation": "초대 이메일 다시 보내기", - "publicLinks": "Public links" + "publicLinks": "Public links", + "editSharing": "공유 편집", + "emptyStateDesc": "다른 사람과 공유한 항목이 여기에 표시됩니다", + "emptyStateTitle": "아직 공유한 항목이 없습니다", + "manageAccess": "접근 권한 관리", + "notifySent": "알림이 전송되었습니다.", + "passwordLinks": "비밀번호로 보호된 링크" }, "sort": { "asc": "ascending", - "desc": "descending" + "desc": "descending", + "direction": "정렬 방향" }, "notif": { "errorTitle": "Error", @@ -1077,7 +1354,15 @@ "category": { "audio": "오디오", "code": "코드", - "text": "텍스트" + "text": "텍스트", + "archives": "압축 파일", + "documents": "문서", + "images": "이미지", + "installers": "설치 프로그램", + "markdown": "마크다운", + "presentations": "프레젠테이션", + "spreadsheets": "스프레드시트", + "videos": "동영상" }, "common": { "add": "추가", @@ -1099,35 +1384,147 @@ "save": "저장", "search": "검색", "yes": "있음", - "saving": "저장 중…" + "saving": "저장 중…", + "copied": "클립보드에 복사되었습니다", + "copy_failed": "복사 실패", + "empty": "아직 아무것도 없습니다.", + "error": "알 수 없는 오류", + "favorite": "즐겨찾기", + "ok": "확인", + "optional": "선택 사항", + "retry": "다시 시도", + "select": "선택", + "select_all": "전체 선택", + "dismiss": "닫기" }, "device": { "continue": "계속", - "unknown": "알 수 없음" + "unknown": "알 수 없음", + "approve": "승인", + "approved": "기기가 승인되었습니다. 원래 기기로 돌아가셔도 됩니다.", + "client": "애플리케이션", + "denied": "기기 접근이 거부되었습니다.", + "deny": "거부", + "enter_code": "기기에 표시된 코드를 입력하세요", + "lookup_failed": "코드 확인에 실패했습니다. 다시 시도해 주세요.", + "not_found": "코드를 찾을 수 없거나 만료되었습니다. 확인 후 다시 시도해 주세요.", + "scopes": "접근 권한", + "title": "기기 인증", + "unauthorized": "기기를 승인하려면 로그인이 필요합니다. 먼저 로그인해 주세요." }, "expiryBucket": { "expired": "만료됨", "noExpiry": "만료 없음", "today": "오늘", - "tomorrow": "내일" + "tomorrow": "내일", + "later": "이후", + "month": "30일 이내", + "week": "7일 이내" }, "nextcloud": { "error_title": "오류", - "sign_in_with": "{{provider}}(으)로 로그인" + "sign_in_with": "{{provider}}(으)로 로그인", + "close_window": "창 닫기", + "error_expired_body": "세션이 만료되었습니다. 다시 시도해 주세요.", + "error_expired_title": "세션 만료", + "error_generic_body": "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요.", + "error_invalid_body": "사용자 이름 또는 비밀번호가 올바르지 않습니다. 자격 증명을 확인한 후 다시 시도해 주세요.", + "error_invalid_title": "로그인 실패", + "error_notfound_body": "요청한 페이지를 찾을 수 없습니다.", + "error_notfound_title": "찾을 수 없음", + "grant": "접근 권한 부여", + "grant_subtitle": "Nextcloud 클라이언트가 회원님의 계정에 대한 접근 권한을 요청하고 있습니다.", + "grant_title": "접근 권한 부여", + "invalid_token": "세션 토큰이 유효하지 않습니다.", + "success_body": "이제 애플리케이션으로 돌아가셔도 됩니다 — 연결이 완료되었습니다.", + "success_title": "접근 권한이 부여되었습니다" }, "search": { "size_label": "크기", "title": "검색", "type": { - "audio": "오디오" + "audio": "오디오", + "all": "모든 유형", + "archive": "압축 파일", + "document": "문서", + "image": "이미지", + "video": "동영상" }, - "type_label": "유형" + "type_label": "유형", + "clear_filters": "필터 지우기", + "date": { + "all": "전체 기간", + "day": "지난 24시간", + "month": "지난 한 달", + "week": "지난 한 주", + "year": "지난 한 해" + }, + "date_label": "날짜", + "everywhere": "모든 위치", + "no_results": "검색 결과가 없습니다", + "prompt": "위 검색창에 검색어를 입력하세요.", + "results_for": "\"{{q}}\"에 대한 검색 결과", + "scope": "범위", + "searching_for": "\"{{q}}\" 검색 중…", + "see_all": "모든 결과 보기", + "size": { + "all": "전체 크기", + "large": "100MB 초과", + "medium": "1–100MB", + "small": "1MB 미만" + }, + "sort": { + "largest": "큰 순", + "name_asc": "이름 오름차순", + "name_desc": "이름 내림차순", + "newest": "최신순", + "oldest": "오래된 순", + "relevance": "관련도순", + "smallest": "작은 순" + }, + "sort_by": "정렬 기준", + "this_folder": "이 폴더" }, "sizeBucket": { - "folders": "폴더" + "folders": "폴더", + "empty": "비어 있음 (0B)", + "huge": "5GB 초과", + "large": "1–5GB", + "medium": "100MB–1GB", + "small": "1–100MB", + "tiny": "1MB 미만" }, "view": { "grid": "그리드 보기", - "list": "목록 보기" + "list": "목록 보기", + "label": "보기 옵션" + }, + "people": { + "unnamed": "이름 없음", + "empty": "아직 인물이 없습니다", + "disabled": "얼굴 인식이 비활성화되어 있습니다", + "rename_title": "이 인물의 이름 지정", + "name_label": "이름", + "back": "뒤로" + }, + "about": { + "description": "OxiCloud — 빠르고 셀프 호스팅 가능한 파일 저장 및 동기화 서버입니다." + }, + "cmdk": { + "no_results": "일치하는 명령이 없습니다", + "placeholder": "명령을 입력하거나 검색하세요…", + "title": "명령 팔레트", + "toggle_theme": "테마 전환" + }, + "errors_loadFailed": "항목을 불러오지 못했습니다", + "settings": { + "language": "언어" + }, + "shared_with_me": { + "empty": "아직 공유받은 항목이 없습니다.", + "from": "{{who}}님이 공유함" + }, + "sortdir": { + "title": "정렬 방향" } } From ba620166eef1397f1043ac03bf624f423805757a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 12 Jul 2026 18:14:15 +0200 Subject: [PATCH 07/12] feat(grant): clean up expired grants --- docs/config/env.md | 5 +- example.env | 13 + src/application/ports/authorization_ports.rs | 20 ++ src/common/config.rs | 64 +++++ src/common/di.rs | 31 +++ .../services/grant_cleanup_service.rs | 124 +++++++++ src/infrastructure/services/mod.rs | 1 + src/infrastructure/services/pg_acl_engine.rs | 21 ++ src/interfaces/api/handlers/admin_handler.rs | 94 +++++++ src/interfaces/api/mod.rs | 7 + tests/api/grant_cleanup.hurl | 243 ++++++++++++++++++ tests/api/run.sh | 1 + 12 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 src/infrastructure/services/grant_cleanup_service.rs create mode 100644 tests/api/grant_cleanup.hurl diff --git a/docs/config/env.md b/docs/config/env.md index cabc50eb..e6e3414c 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -69,7 +69,10 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search | | `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | -| `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. | +| `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep`, `POST /api/admin/internal/trigger-gc`, and `POST /api/admin/internal/trigger-grant-cleanup` — test-only synchronous triggers for the storage-usage reconciliation sweep, blob garbage collector, and expired-grant purge respectively. Used by the API test suite to assert convergence deterministically without waiting out the periodic tickers. Leave **off** in production: the routes return 404 even to an admin token when disabled. | +| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). | +| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. | +| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. | | `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | ## Storage Backend diff --git a/example.env b/example.env index b814ae7d..5676c70f 100644 --- a/example.env +++ b/example.env @@ -230,6 +230,19 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Enable trash/recycle bin functionality (default: true) #OXICLOUD_ENABLE_TRASH=true +# Background daemon that deletes expired `storage.role_grants` rows. +# The AuthZ engine already filters expired grants out of every +# permission check at read time, so leaving expired rows in place is +# a hygiene issue — not a security one. This purge deletes rows +# whose `expires_at` is more than GRACE_DAYS in the past, preserving +# the audit / support answer to "what happened to my access?" for +# the grace window. +# +# Default: enabled. Recommended grace: >= 15 days. +#OXICLOUD_GRANT_CLEANUP_ENABLED=true +#OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15 +#OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24 + # Enable search functionality (default: true) #OXICLOUD_ENABLE_SEARCH=true diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 99144483..6a02f696 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -150,6 +150,26 @@ pub trait AuthorizationEngine: Send + Sync + 'static { expires_at: Option>, ) -> Result<(), DomainError>; + /// Delete every row from `storage.role_grants` whose `expires_at` is + /// more than `grace_days` in the past. Returns the count of rows + /// removed. + /// + /// The engine's `check` / `list_grants_*` paths already ignore + /// expired rows (they filter on `expires_at > NOW()` in-query), so + /// this is pure garbage collection — no live authorization decision + /// changes. The grace window preserves the audit / support answer + /// to "what happened to my access?" for a couple of weeks past + /// expiration. + /// + /// Grace of `0` means "delete every row whose `expires_at` is in + /// the past, right now" — used by the admin `?force=true` trigger + /// endpoint to enable Hurl regression testing without waiting the + /// configured grace out. + /// + /// Rows with `expires_at IS NULL` (permanent grants) are never + /// touched. + async fn purge_expired_grants(&self, grace_days: u32) -> Result; + /// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())` /// whether or not the row existed. The id comes from a prior listing /// or `find_grant_full_by_id` lookup. diff --git a/src/common/config.rs b/src/common/config.rs index a5fc1f79..01162899 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -931,6 +931,50 @@ pub struct FeaturesConfig { /// /// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. pub webdav_drive_listing_prefix: String, + + /// Background purge of expired `storage.role_grants` rows. + /// + /// The AuthZ engine already filters expired grants out of every + /// permission check at read time (`expires_at IS NULL OR + /// expires_at > NOW()`), so leaving the rows in place is a + /// hygiene issue — not a security one. This purge deletes rows + /// whose `expires_at` is more than [`GrantCleanupConfig::grace_days`] + /// in the past, preserving the audit / support answer to + /// "what happened to my access?" for the grace window. + /// + /// Enabled by default: expired-auth-row cleanup is a + /// security-hygiene default, not opt-in. + pub grant_cleanup: GrantCleanupConfig, +} + +/// Config for the daily expired-grant purge (see +/// [`FeaturesConfig::grant_cleanup`]). +#[derive(Debug, Clone)] +pub struct GrantCleanupConfig { + /// Master switch. Env: `OXICLOUD_GRANT_CLEANUP_ENABLED` + /// (default `true`). + pub enabled: bool, + /// Days past a grant's `expires_at` before the row is eligible + /// for deletion. Env: `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` + /// (default `15`). + /// + /// The recommendation is `> 15` — enough to answer + /// support/audit questions about recently-lapsed grants without + /// keeping dead rows forever. + pub grace_days: u32, + /// How often the daemon fires, in hours. Env: + /// `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` (default `24`). + pub interval_hours: u64, +} + +impl Default for GrantCleanupConfig { + fn default() -> Self { + Self { + enabled: true, + grace_days: 15, + interval_hours: 24, + } + } } impl Default for FeaturesConfig { @@ -954,6 +998,7 @@ impl Default for FeaturesConfig { // maps to the caller's default drive; drive listing is // reachable at `/webdav/@drive/`. webdav_drive_listing_prefix: "@drive".to_string(), + grant_cleanup: GrantCleanupConfig::default(), } } } @@ -1525,6 +1570,25 @@ impl AppConfig { config.features.enable_admin_internal_endpoints = val; } + // Grant-cleanup daemon. Purges rows from `storage.role_grants` + // whose `expires_at` is more than `grace_days` in the past. + // See `GrantCleanupConfig` for defaults + rationale. + if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_ENABLED").map(|v| v.parse::()) + && let Ok(val) = v + { + config.features.grant_cleanup.enabled = val; + } + if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_GRACE_DAYS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.features.grant_cleanup.grace_days = val; + } + if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.features.grant_cleanup.interval_hours = val.max(1); + } + // Native WebDAV drive-picker path segment. Sanitised by // stripping leading/trailing slashes so operators can pass // `/drives/` or `drives` interchangeably; empty string means diff --git a/src/common/di.rs b/src/common/di.rs index b6dfa469..5b78fbc5 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1290,6 +1290,9 @@ impl AppServiceFactory { let places_service: Option>; let people_service: Option>; let storage_usage_service: Option>; + let grant_cleanup_service: Option< + Arc, + >; let mut auth_services: Option = None; let mut nextcloud_services: Option = None; // Lifted out of the database-services block so PR 9's invite @@ -1333,6 +1336,25 @@ impl AppServiceFactory { self.start_content_index_job(&maintenance_pool, &core, content_index); + grant_cleanup_service = if core.config.features.grant_cleanup.enabled { + let svc = Arc::new( + crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new( + authorization.clone(), + core.config.features.grant_cleanup.grace_days, + core.config.features.grant_cleanup.interval_hours, + ), + ); + // First tick fires immediately inside start_cleanup_job — + // matches the trash/storage-usage daemon shape. + svc.clone().start_cleanup_job().await; + Some(svc) + } else { + tracing::info!( + "Grant-cleanup daemon disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false" + ); + None + }; + // User-lifecycle dispatcher. Hook order is registration order; // document dependencies inline if/when any arise. Today: // 1. AuditLifecycleHook — fires first so the @@ -1557,6 +1579,7 @@ impl AppServiceFactory { places_service, people_service, storage_usage_service, + grant_cleanup_service, calendar_service: None, calendar_use_case: None, addressbook_use_case: None, @@ -2029,6 +2052,14 @@ pub struct AppState { pub places_service: Option>, pub people_service: Option>, pub storage_usage_service: Option>, + /// Handle to the background daemon that purges expired + /// `storage.role_grants` rows. `None` when the daemon is disabled + /// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin + /// `POST /api/admin/internal/trigger-grant-cleanup` handler uses + /// this to invoke the purge on demand (test-only). + pub grant_cleanup_service: Option< + Arc, + >, pub calendar_service: Option>, pub calendar_use_case: Option>, pub addressbook_use_case: Option>, diff --git a/src/infrastructure/services/grant_cleanup_service.rs b/src/infrastructure/services/grant_cleanup_service.rs new file mode 100644 index 00000000..54435606 --- /dev/null +++ b/src/infrastructure/services/grant_cleanup_service.rs @@ -0,0 +1,124 @@ +//! Background daemon that purges expired `storage.role_grants` rows. +//! +//! The AuthZ engine already filters expired grants out of every +//! permission check at read time (`expires_at IS NULL OR +//! expires_at > NOW()` on every `check` / `list_grants_*` path in +//! `PgAclEngine`), so expired rows never leak permission. They just +//! accumulate. This daemon garbage-collects them once per +//! [`GrantCleanupService::interval_hours`], with a grace window past +//! `expires_at` that preserves the audit / support answer to "what +//! happened to my access?" for a few weeks. +//! +//! Shape mirrors [`TrashCleanupService`] verbatim (fire-and-forget +//! `tokio::spawn`, `tokio::time::interval`, first-tick-immediate). The +//! authoritative pattern for background daemons in this codebase; see +//! the plan doc `docs/plan/` (deferred future work: fold all daemons +//! into a central `JobRegistry` that plugins can also register into). +//! +//! [`TrashCleanupService`]: crate::infrastructure::services::trash_cleanup_service::TrashCleanupService + +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::time; +use tracing::{error, info}; + +use crate::application::ports::authorization_ports::AuthorizationEngine; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; + +/// Daemon that periodically deletes expired grants. +/// +/// Owns an `Arc` (not a `dyn AuthorizationEngine`) to avoid +/// the wrapper allocation on every SQL call — the daemon is the sole +/// caller of `purge_expired_grants` outside of the admin trigger +/// endpoint, both statically dispatched. +pub struct GrantCleanupService { + authz: Arc, + grace_days: u32, + interval_hours: u64, +} + +impl GrantCleanupService { + pub fn new(authz: Arc, grace_days: u32, interval_hours: u64) -> Self { + Self { + authz, + grace_days, + // Minimum 1 hour — matches TrashCleanupService's clamp so + // a mis-set `0` doesn't spin a hot loop. + interval_hours: interval_hours.max(1), + } + } + + /// Grace period the daemon uses on its scheduled ticks. Exposed + /// for the admin trigger's default-response field. + pub fn grace_days(&self) -> u32 { + self.grace_days + } + + /// Fire-and-forget the periodic purge. Never joins; killed + /// implicitly at `tokio::runtime::shutdown`. + pub async fn start_cleanup_job(self: Arc) { + let interval_hours = self.interval_hours; + let grace_days = self.grace_days; + info!( + "Starting grant-cleanup daemon: every {}h, grace = {}d", + interval_hours, grace_days + ); + + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(interval_hours * 60 * 60)); + // First tick fires immediately — matches TrashCleanupService. + // Any accumulated backlog at boot gets flushed straight away. + loop { + interval.tick().await; + self.run_once().await; + } + }); + } + + /// One scheduled pass. Also called by the admin trigger endpoint + /// (via a shared `Arc` on `AppState`). + /// + /// `grace_override`: + /// - `None` → use the configured grace (`self.grace_days`). + /// - `Some(n)` → override with `n`. The admin `?force=true` trigger + /// passes `Some(0)` so Hurl regressions can hit expired grants + /// without waiting the configured grace out. + pub async fn purge(&self, grace_override: Option) -> u64 { + let grace = grace_override.unwrap_or(self.grace_days); + let start = Instant::now(); + match self.authz.purge_expired_grants(grace).await { + Ok(count) => { + // Audit-channel logging: bulk deletion of authorization + // rows is security-relevant enough to keep it in the + // audit stream even when the count is zero (proves the + // daemon is reachable). + info!( + target: "audit", + event = "grant_cleanup.purged", + count = count, + grace_days = grace, + elapsed_ms = start.elapsed().as_millis() as u64, + "👮🏻‍♂️ Purged {} expired grant(s) older than {} days", + count, + grace, + ); + count + } + Err(e) => { + error!( + target: "audit", + event = "grant_cleanup.failed", + grace_days = grace, + error = %e, + "Grant cleanup failed" + ); + 0 + } + } + } + + /// Convenience for the scheduled loop. + async fn run_once(&self) { + let _ = self.purge(None).await; + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 9e65f351..0f85ea61 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -12,6 +12,7 @@ pub mod face_indexing_service; pub mod ffmpeg_video_frame_service; pub mod file_content_cache; pub mod file_system_i18n_service; +pub mod grant_cleanup_service; pub mod image_transcode_service; pub mod jwt_service; pub mod local_blob_backend; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 4d69e7fe..ec60aba3 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -2075,6 +2075,27 @@ impl AuthorizationEngine for PgAclEngine { Ok(()) } + async fn purge_expired_grants(&self, grace_days: u32) -> Result { + // Uses the partial index `idx_role_grants_expires_at` (migration + // 20260730000000), which covers `WHERE expires_at IS NOT NULL` + // — so this DELETE only touches indexed rows even when the + // `role_grants` table has tens of millions of permanent grants. + // + // Grace days is bound as bigint and multiplied into an + // interval — parameterised, no injection surface. u32 → i64 + // is loss-free. + let result = sqlx::query( + "DELETE FROM storage.role_grants \ + WHERE expires_at IS NOT NULL \ + AND expires_at < NOW() - ($1::bigint * INTERVAL '1 day')", + ) + .bind(grace_days as i64) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("purge_expired_grants: {e}")))?; + Ok(result.rows_affected()) + } + async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { sqlx::query("DELETE FROM storage.role_grants WHERE id = $1") .bind(grant_id) diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index b73e69ff..d285b5e2 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -103,6 +103,10 @@ pub fn admin_routes() -> Router> { // deployments don't need a different route table. .route("/internal/trigger-sweep", post(internal_trigger_sweep)) .route("/internal/trigger-gc", post(internal_trigger_gc)) + .route( + "/internal/trigger-grant-cleanup", + post(internal_trigger_grant_cleanup), + ) // Drives — admin-wide view (distinct from `/api/drives` which // is filtered to the caller's role grants). .route("/drives", get(list_all_drives)) @@ -2160,3 +2164,93 @@ pub async fn internal_trigger_gc( Err(e) => AppError::internal_error(format!("gc failed: {e}")).into_response(), } } + +/// Query parameters for `POST /api/admin/internal/trigger-grant-cleanup`. +/// +/// `force=true` sets the grace window to `0` for this call — deletes +/// every row whose `expires_at` is in the past, right now. Enables +/// Hurl regressions to plant a past-dated grant and immediately +/// observe it purged, without waiting the configured +/// `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` out. +/// +/// Without `force`, the daemon's configured grace applies — the same +/// SQL the daily loop runs. +#[derive(Debug, serde::Deserialize, Default)] +pub struct InternalTriggerGrantCleanupQuery { + #[serde(default)] + pub force: bool, +} + +/// `POST /api/admin/internal/trigger-grant-cleanup` — run the expired- +/// grant purge synchronously. +/// +/// Test-only. Deletes rows from `storage.role_grants` whose +/// `expires_at` is more than `grace_days` in the past (or immediately, +/// with `?force=true`). Same SQL as the periodic `GrantCleanupService` +/// daemon — exposed under an admin route so Hurl can wait for it +/// deterministically. +/// +/// Response fields: +/// `grants_deleted` — count of rows removed by this invocation +/// `grace_days` — the grace window that was applied (0 when +/// `?force=true`, otherwise the config value) +/// `forced` — echoes the query param +#[utoipa::path( + post, + path = "/api/admin/internal/trigger-grant-cleanup", + params(("force" = Option, Query, description = "Force grace = 0 for this run (test-only)")), + responses( + (status = 200, description = "Purge ran"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"), + (status = 503, description = "Grant-cleanup daemon disabled (OXICLOUD_GRANT_CLEANUP_ENABLED=false)"), + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn internal_trigger_grant_cleanup( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> axum::response::Response { + use axum::response::IntoResponse; + if !state.core.config.features.enable_admin_internal_endpoints { + return internal_endpoints_disabled(); + } + if let Err(e) = admin_guard(&state, &headers).await { + return e.into_response(); + } + // Daemon may be disabled by config even when the internal-endpoint + // gate is on. Return 503 (rather than 404 or 500) so integration + // tests can distinguish "surface not exposed" from "surface + // exposed but backing service off". + let svc = match state.grant_cleanup_service.as_ref() { + Some(s) => s, + None => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "grant_cleanup_service not available (disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false)", + })), + ) + .into_response(); + } + }; + // `force=true` collapses the grace window to zero for this run + // only — the daemon's configured grace is untouched. Mirrors the + // `trigger-gc?force=true` shape. + let grace_override = if query.force { Some(0) } else { None }; + let grants_deleted = svc.purge(grace_override).await; + let grace_days = grace_override.unwrap_or_else(|| svc.grace_days()); + ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "grants_deleted": grants_deleted, + "grace_days": grace_days, + "forced": query.force, + })), + ) + .into_response() +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 06d87a8c..f9277935 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -225,6 +225,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::admin_handler::complete_migration, handlers::admin_handler::verify_migration, handlers::admin_handler::generate_encryption_key, + // Admin internal-trigger handlers — gated by + // OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS (Off by default in + // prod; on for the Hurl suite). Documented in OpenAPI so + // integrators writing test harnesses can discover the surface. + handlers::admin_handler::internal_trigger_sweep, + handlers::admin_handler::internal_trigger_gc, + handlers::admin_handler::internal_trigger_grant_cleanup, // Grant / ReBAC handlers (free functions) handlers::grant_handler::create_grant, handlers::grant_handler::revoke_grant, diff --git a/tests/api/grant_cleanup.hurl b/tests/api/grant_cleanup.hurl new file mode 100644 index 00000000..2ab47ba8 --- /dev/null +++ b/tests/api/grant_cleanup.hurl @@ -0,0 +1,243 @@ +# ============================================================= +# OxiCloud — Expired-grant purge (GrantCleanupService) +# ============================================================= +# Regression coverage for the daily purge that deletes rows from +# `storage.role_grants` whose `expires_at` is more than +# `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` in the past. +# +# The engine's `check` / `list_grants_*` paths already filter +# expired grants out at read time — this purge is pure garbage +# collection. If the SQL were wrong (e.g. missing +# `expires_at IS NOT NULL`, wrong sign on the interval), the +# assertions here catch it before the daemon runs against real +# data. +# +# Uses the `POST /api/admin/internal/trigger-grant-cleanup` +# admin endpoint (gated by +# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`, on for the +# api-test suite). `?force=true` collapses the grace window to +# zero for the call so we can plant a past-dated grant and +# immediately observe it purged, without waiting 15+ days. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login admin (Alice), capture home folder id. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a grantee user (mallory) — someone we can +# grant Alice's resources to without polluting shared +# state used by other test files. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "gc-mallory", + "password": "GcMalloryPassword1!", + "email": "gc-mallory@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +mallory_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Alice creates two folders: one to hold an expired +# grant, one to hold a permanent (no-expiry) grant we +# expect the purge to leave alone. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "gc-expired", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +expired_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "gc-permanent", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +permanent_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Plant an expired grant. Set `expires_at` in 2020 so +# any grace window less than several years still +# catches it. The grant handler silently accepts past- +# dated `expires_at` — a separate PR would reject them +# on the create path, but here we exploit the +# permissive behaviour as a test fixture. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{mallory_user_id}}" }, + "resource": { "type": "folder", "id": "{{expired_folder_id}}" }, + "role": "viewer", + "expires_at": "2020-01-01T00:00:00Z" +} + +HTTP 201 +[Captures] +expired_grant_id: jsonpath "$.grants[0].id" + + +# Confirm the grant IS present in the listing — the engine's +# filter is `expires_at > NOW()`, so the past-dated row is +# already invisible to `check()` but still exists physically +# (and thus in the list endpoint too — verified below). +GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# Bare array, filter selector — see memory note on Hurl JSONPath +# quirks: use `$[?(...)]` (single-match returns scalar; no `nth`). +jsonpath "$[?(@.id=='{{expired_grant_id}}')].role" == "viewer" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Plant a permanent grant on the other folder (no +# `expires_at`). The purge MUST leave it alone. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{mallory_user_id}}" }, + "resource": { "type": "folder", "id": "{{permanent_folder_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +permanent_grant_id: jsonpath "$.grants[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Trigger the purge with `force=true`. The endpoint +# collapses the grace window to 0 for this call only +# — the daemon's configured grace is untouched. +# +# Expect `grants_deleted >= 1` (the past-dated row), +# `grace_days == 0`, `forced == true`. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.ok" == true +jsonpath "$.forced" == true +jsonpath "$.grace_days" == 0 +# At least the expired-fixture row we just planted. +jsonpath "$.grants_deleted" >= 1 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — The expired grant is gone. The permanent grant +# survives. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# The list is either empty or contains no row with the expired +# grant's id — the filter must not select anything. +jsonpath "$[*].id" not contains "{{expired_grant_id}}" + +GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# Permanent grant untouched. +jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer" + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Second trigger with `force=true` on a table that no +# longer has any past-dated grants. Expect +# `grants_deleted == 0`. This is the regression guard +# on the WHERE clause — if `expires_at IS NOT NULL` +# were missing, this would nuke the permanent grant +# from Step 5 (any row with `NULL < NOW() - 0 days` is +# false in SQL, so it's already correct; but a +# mistyped predicate could regress). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.grants_deleted" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Unforced trigger. Grace = configured value (15). +# No new expired grants planted, so purge is a no-op. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/internal/trigger-grant-cleanup +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.ok" == true +jsonpath "$.forced" == false +# Response echoes the configured grace (15 days by default). +jsonpath "$.grace_days" == 15 +jsonpath "$.grants_deleted" == 0 + + +# Permanent grant still there after the unforced call. +GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — drop both folders. Cascade removes the remaining +# grant + any children. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{expired_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +DELETE {{base_url}}/api/folders/{{permanent_folder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 diff --git a/tests/api/run.sh b/tests/api/run.sh index 5f734bb1..a62f0406 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -166,6 +166,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \ + "$API_DIR/grant_cleanup.hurl" \ "$API_DIR/role_grants.hurl" \ "$API_DIR/subject_groups.hurl" \ "$API_DIR/groups_effective_members.hurl" \ From b6df05f4d8d2e80794563c7da1527ee5e21dab18 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 12 Jul 2026 18:53:39 +0200 Subject: [PATCH 08/12] refactor: rustc 1.97.0 (useless borrows in formatting) --- src/infrastructure/services/thumbnail_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index cbcd2b2a..5f8913cd 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { let path = root.join(size.dir_name()) - .join(format!("{}.{}", &blob_hash, format.ext())); + .join(format!("{}.{}", blob_hash, format.ext())); if tokio::fs::metadata(&path).await.is_ok() { let _ = tokio::fs::remove_file(&path).await; } From f4d2a7cd618dcf4836563d4d2df719292b108b0b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 12 Jul 2026 21:54:18 +0200 Subject: [PATCH 09/12] fix(front): unregister cache prio to 0.8.0 this fix issue https://github.com/AtalayaLabs/OxiCloud/issues/560 previous version where caching assets, now sveltekit is fully autonomous, use a sw.js that clears the cache and unregisters it self --- frontend/static/sw.js | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/static/sw.js diff --git a/frontend/static/sw.js b/frontend/static/sw.js new file mode 100644 index 00000000..ddfe4168 --- /dev/null +++ b/frontend/static/sw.js @@ -0,0 +1,69 @@ +// Self-unregistering stub — replaces the legacy vanilla-frontend +// service worker that shipped with OxiCloud ≤ 0.8.0. +// +// Browsers that installed the old SW keep it registered across upgrades +// and it intercepts every navigation, serving a stale index.html from its +// `oxicloud-cache*` Cache Storage. The stale shell's meta-CSP predates +// the SvelteKit build's inline-script hashes, so hydration is blocked by +// CSP and the app hangs on the spinner. Symptom: infinite loader on +// fresh visits, only cleared by a hard refresh. Ref: issue #560. +// +// SvelteKit itself does NOT register a service worker (no `src/service-worker` +// module exists) — this file exists solely to shepherd upgraders off the +// legacy SW. Browsers on a clean install fetch it, install it, immediately +// unregister it, and the URL stays a 200 for the next visitor with the +// same stale-SW problem. +// +// The install/activate handlers race the browser's normal SW lifecycle; +// `skipWaiting` + `clients.claim` fast-forward through the "waiting" and +// "activating" states so the tab that triggered the update gets reloaded +// with a controller-less document (no SW intercepting fetches) within +// the same page lifetime. + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + // 1. Drop every Cache Storage bucket the legacy SW may have + // populated. We match the `oxicloud-cache*` prefix the old + // SW used, plus a defensive wildcard clear if that prefix + // was ever changed in a fork/downstream build. + if (self.caches) { + const keys = await self.caches.keys(); + await Promise.all(keys.map((k) => self.caches.delete(k))); + } + + // 2. Unregister this SW. After this the browser will not + // invoke `fetch` handlers from this registration on future + // navigations. + await self.registration.unregister(); + + // 3. Take control of open clients so we can reload them into + // a controller-less state (fresh HTML, matching CSP). + await self.clients.claim(); + const clients = await self.clients.matchAll({ type: 'window' }); + for (const client of clients) { + // `navigate` beats `location.reload()`-in-postMessage because + // it works even if the page's JS is CSP-blocked (the case + // we're fixing). Same URL → same-tab reload without controller. + try { + await client.navigate(client.url); + } catch { + /* opaque redirect / cross-origin — nothing we can do */ + } + } + })() + ); +}); + +// Explicit pass-through fetch handler. Without one, browsers may treat +// the SW as controlling — with an empty handler they short-circuit to +// the network. Belt-and-suspenders: we've already unregistered above, +// but a race between activation and an in-flight navigation could still +// hit this handler. +self.addEventListener('fetch', () => { + /* fall through to network */ +}); From f854da30f00b2593a2102c3d569ee141ba07848d Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 12 Jul 2026 18:53:39 +0200 Subject: [PATCH 10/12] refactor: rustc 1.97.0 (useless borrows in formatting) --- src/infrastructure/services/thumbnail_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index cbcd2b2a..5f8913cd 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] { let path = root.join(size.dir_name()) - .join(format!("{}.{}", &blob_hash, format.ext())); + .join(format!("{}.{}", blob_hash, format.ext())); if tokio::fs::metadata(&path).await.is_ok() { let _ = tokio::fs::remove_file(&path).await; } From 230927a80e365f71d0ef7540bdded4552160664a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 12 Jul 2026 21:07:34 +0200 Subject: [PATCH 11/12] fix(templates): ensure template use frontend css this fix the nextcloud login + drive selector (chroot) fix also invitation / magic link also correct the UX: once user has logged in nextcloud, show an explicita page --- frontend/package.json | 1 + frontend/scripts/emit-askama-common.mjs | 58 +++++ frontend/src/lib/styles/askama-common.css | 221 ++++++++++++++++++ .../src/routes/nextcloud/error/+page.svelte | 17 +- .../src/routes/nextcloud/success/+page.svelte | 16 +- frontend/vite.config.ts | 7 + src/interfaces/nextcloud/login_v2_handler.rs | 29 ++- .../page_cross_browser_confirm.html | 24 +- .../magic_link/page_expired_or_used.html | 4 +- templates/magic_link/page_generic_error.html | 4 +- .../magic_link/page_resend_confirmation.html | 4 +- templates/nextcloud/drive_picker.html | 4 +- 12 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 frontend/scripts/emit-askama-common.mjs create mode 100644 frontend/src/lib/styles/askama-common.css diff --git a/frontend/package.json b/frontend/package.json index 4a2a1954..917c16a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "postbuild": "node scripts/emit-askama-common.mjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/frontend/scripts/emit-askama-common.mjs b/frontend/scripts/emit-askama-common.mjs new file mode 100644 index 00000000..6c390745 --- /dev/null +++ b/frontend/scripts/emit-askama-common.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/* + * Emit `static-dist/askama-common.css` from the SvelteKit design-token + * source of truth (`src/lib/styles/base/variables.css`) plus the auth-page + * component styles (`src/lib/styles/askama-common.css`). + * + * WHY A POST-BUILD SCRIPT: + * Vite's `writeBundle` hooks fire mid-build, before + * `@sveltejs/adapter-static` copies the finalised site to + * `../static-dist/`. Anything written to that directory during + * Vite gets wiped when adapter-static runs. A `postbuild` script + * runs after everything the SvelteKit build owns, so its output + * survives — one predictable moment, no ordering trap. + * + * WHAT IT PRODUCES: + * A single stable-named CSS file at `static-dist/askama-common.css` + * containing: + * 1. Every design token declared in `base/variables.css` (:root, + * `light-dark(...)`, dark-mode blocks, etc.) + * 2. The auth-page component rules from `askama-common.css` + * Concatenated, prefixed with a "do not edit" header, written UTF-8. + * + * SINGLE SOURCE OF TRUTH: + * If a token changes in `variables.css`, one rebuild propagates it to + * both the SPA (via Svelte's normal build pipeline) AND the askama + * templates (via this file). Two consumers, one source. No manual + * sync step. + * + * SERVER SIDE: + * Server-rendered askama templates reference: + * + * The Rust web layer serves `static-dist/askama-common.css` at that + * URL through the same ServeDir the SPA uses. No route wiring needed. + */ + +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const stylesDir = resolve(__dirname, '../src/lib/styles'); +const outputFile = resolve(__dirname, '../../static-dist/askama-common.css'); + +const header = + '/* Auto-generated by frontend/scripts/emit-askama-common.mjs.\n' + + ' * Do NOT edit by hand — regenerated on every `npm run build`.\n' + + ' * Sources: src/lib/styles/base/variables.css (design tokens)\n' + + ' * src/lib/styles/askama-common.css (auth components)\n' + + ' */\n\n'; + +const tokens = readFileSync(resolve(stylesDir, 'base/variables.css'), 'utf8'); +const components = readFileSync(resolve(stylesDir, 'askama-common.css'), 'utf8'); + +mkdirSync(dirname(outputFile), { recursive: true }); +writeFileSync(outputFile, header + tokens + '\n' + components, 'utf8'); + +const bytes = Buffer.byteLength(header + tokens + '\n' + components, 'utf8'); +console.log(`emit-askama-common: wrote ${bytes} bytes → ${outputFile}`); diff --git a/frontend/src/lib/styles/askama-common.css b/frontend/src/lib/styles/askama-common.css new file mode 100644 index 00000000..4fb38f4b --- /dev/null +++ b/frontend/src/lib/styles/askama-common.css @@ -0,0 +1,221 @@ +/* + * askama-common.css — component styles for server-rendered askama pages. + * + * BUILD PIPELINE: + * `vite.config.ts` prepends `base/variables.css` at build time (the + * `emitAskamaCommon` plugin) and writes the result to + * `static-dist/askama-common.css`. That output is the single non-hashed + * URL every askama template references: + * + * + * + * SINGLE SOURCE OF TRUTH: + * Design tokens (`--color-*`, `--space-*`, `--radius-*`, `--text-*`, + * etc.) live in `base/variables.css`. This file only carries the + * component-level rules for the class vocabulary the askama templates + * actually use. Update tokens in ONE place; the build packages both. + * + * NO JAVASCRIPT: + * Dark-mode detection uses `light-dark()` + the `color-scheme` on + * :root (declared in `variables.css`). Askama pages are pre-auth flows + * (login / magic-link error) — no per-user override needed. Browsers + * older than Chrome 123 / Safari 17.5 / Firefox 120 fall back to the + * light values; the pages are readable either way. + * + * CLASS VOCABULARY (mirrors `grep 'class=' templates/**\/*.html`): + * .auth-container .auth-panel + * .auth-logo .auth-logo-icon .auth-logo-text + * .auth-title .auth-subtitle + * .auth-form .auth-button + * .auth-drive-option .auth-drive-name .auth-drive-badge + * .magic-note + */ + +/* ── Reset ─────────────────────────────────────────────────────── */ + +html, +body { + margin: 0; + padding: 0; + height: 100%; +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-normal); + background: var(--color-bg-page); + color: var(--color-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* ── Layout shell ──────────────────────────────────────────────── */ + +.auth-container { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); +} + +.auth-panel { + width: min(400px, 100%); + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-md); + padding: var(--space-8); +} + +/* ── Logo strip ───────────────────────────────────────────────── */ + +.auth-logo { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.auth-logo-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-lg); + background: var(--color-accent); + display: grid; + place-items: center; +} + +.auth-logo-icon svg { + width: 26px; + height: 26px; +} + +.auth-logo-text { + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + color: var(--color-text-heading); +} + +/* ── Title strip ──────────────────────────────────────────────── */ + +.auth-title { + margin: 0 0 var(--space-2); + font-size: var(--text-xl); + font-weight: var(--weight-semibold); + color: var(--color-text-heading); + line-height: var(--leading-snug); +} + +.auth-subtitle { + margin: 0 0 var(--space-5); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.auth-subtitle a { + color: var(--color-accent); + text-decoration: none; +} + +.auth-subtitle a:hover { + text-decoration: underline; +} + +/* ── Form + button ────────────────────────────────────────────── */ + +.auth-form { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.auth-button { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 0; + border-radius: var(--radius-lg); + background: var(--color-accent); + color: var(--color-on-accent); + font: inherit; + font-weight: var(--weight-semibold); + cursor: pointer; + transition: background 0.12s ease; +} + +.auth-button:hover { + background: var(--color-accent-hover); +} + +.auth-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* ── Drive picker radios ──────────────────────────────────────── */ + +.auth-drive-option { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + cursor: pointer; + transition: + border-color 0.12s ease, + background 0.12s ease; +} + +.auth-drive-option:hover { + border-color: var(--color-border-medium); + background: var(--color-bg-hover); +} + +.auth-drive-option:has(input:checked) { + border-color: var(--color-accent); + background: var(--color-accent-ring); +} + +.auth-drive-option input[type='radio'] { + accent-color: var(--color-accent); + margin: 0; +} + +.auth-drive-name { + flex: 1; + font-weight: var(--weight-medium); +} + +.auth-drive-badge { + padding: 2px var(--space-2); + border-radius: 999px; + background: var(--color-accent); + color: var(--color-on-accent); + font-size: 0.6875rem; + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ── Magic-link note block ────────────────────────────────────── */ + +.magic-note { + margin-top: var(--space-5); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + background: var(--color-bg-input); + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: var(--text-sm); +} diff --git a/frontend/src/routes/nextcloud/error/+page.svelte b/frontend/src/routes/nextcloud/error/+page.svelte index 5bb7e153..60b9f183 100644 --- a/frontend/src/routes/nextcloud/error/+page.svelte +++ b/frontend/src/routes/nextcloud/error/+page.svelte @@ -67,7 +67,7 @@ {view.title} · OxiCloud
- +

{view.title}

{view.message}