diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 27145ef6..8423e07c 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -17,6 +17,7 @@ pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; pub mod recent_service; pub mod search_service; +pub mod share_browse_service; pub mod share_service; pub mod storage_settings_service; pub mod storage_usage_service; diff --git a/src/application/services/share_browse_service.rs b/src/application/services/share_browse_service.rs new file mode 100644 index 00000000..a52eceaa --- /dev/null +++ b/src/application/services/share_browse_service.rs @@ -0,0 +1,191 @@ +//! Share-scoped folder browsing for public folder shares. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::dtos::folder_listing_dto::FolderListingDto; +use crate::application::ports::file_ports::FileRetrievalUseCase; +use crate::application::ports::inbound::FolderUseCase; +use crate::application::services::file_retrieval_service::FileRetrievalService; +use crate::application::services::folder_service::FolderService; +use crate::application::services::share_service::ShareService; +use crate::common::errors::DomainError; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; + +struct ResolvedFolderShare { + root_folder_id: String, + owner_id: Uuid, + display_name: String, +} + +pub struct ZipTarget { + pub folder_id: String, + pub display_name: String, +} + +pub struct ShareBrowseService { + share_service: Arc, + folder_service: Arc, + file_retrieval: Arc, + folder_repo: Arc, +} + +impl ShareBrowseService { + pub fn new( + share_service: Arc, + folder_service: Arc, + file_retrieval: Arc, + folder_repo: Arc, + ) -> Self { + Self { + share_service, + folder_service, + file_retrieval, + folder_repo, + } + } + + async fn resolve_folder_share( + &self, + token: &str, + unlock_jwt: Option<&str>, + ) -> Result { + let share = self + .share_service + .get_shared_link_with_unlock(token, unlock_jwt) + .await?; + + if share.item_type != "folder" { + return Err(DomainError::validation_error( + "This endpoint is only valid for folder shares", + )); + } + + let owner_id = Uuid::parse_str(&share.created_by).map_err(|_| { + DomainError::internal_error( + "Share", + format!("Share has invalid created_by UUID: {}", share.created_by), + ) + })?; + + let display_name = match share.item_name { + Some(name) => name, + None => self + .folder_service + .get_folder(&share.item_id) + .await + .map(|f| f.name) + .unwrap_or_else(|_| "Shared folder".to_string()), + }; + + Ok(ResolvedFolderShare { + root_folder_id: share.item_id, + owner_id, + display_name, + }) + } + + pub async fn list_root( + &self, + token: &str, + unlock_jwt: Option<&str>, + ) -> Result { + let resolved = self.resolve_folder_share(token, unlock_jwt).await?; + self.list_inner(&resolved.root_folder_id, resolved.owner_id) + .await + } + + pub async fn list_subfolder( + &self, + token: &str, + folder_id: &str, + unlock_jwt: Option<&str>, + ) -> Result { + let resolved = self.resolve_folder_share(token, unlock_jwt).await?; + + if !self + .folder_repo + .is_folder_in_subtree(folder_id, &resolved.root_folder_id) + .await? + { + return Err(DomainError::not_found("Folder", folder_id)); + } + + self.list_inner(folder_id, resolved.owner_id).await + } + + pub async fn assert_file_in_share( + &self, + token: &str, + file_id: &str, + unlock_jwt: Option<&str>, + ) -> Result<(), DomainError> { + let resolved = self.resolve_folder_share(token, unlock_jwt).await?; + + if !self + .folder_repo + .is_file_in_subtree(file_id, &resolved.root_folder_id) + .await? + { + return Err(DomainError::not_found("File", file_id)); + } + Ok(()) + } + + pub async fn resolve_zip_target( + &self, + token: &str, + folder_id: Option<&str>, + unlock_jwt: Option<&str>, + ) -> Result { + let resolved = self.resolve_folder_share(token, unlock_jwt).await?; + + let target_folder_id = match folder_id { + None => resolved.root_folder_id.clone(), + Some(id) => { + if !self + .folder_repo + .is_folder_in_subtree(id, &resolved.root_folder_id) + .await? + { + return Err(DomainError::not_found("Folder", id)); + } + id.to_string() + } + }; + + let display_name = if folder_id.is_none() { + resolved.display_name + } else { + self.folder_service + .get_folder(&target_folder_id) + .await + .map(|f| f.name) + .unwrap_or_else(|_| "shared".to_string()) + }; + + Ok(ZipTarget { + folder_id: target_folder_id, + display_name, + }) + } + + async fn list_inner( + &self, + parent_folder_id: &str, + owner_id: Uuid, + ) -> Result { + let (folders_res, files_res) = tokio::join!( + self.folder_service + .list_folders_for_owner(Some(parent_folder_id), owner_id), + self.file_retrieval + .list_files_owned(Some(parent_folder_id), owner_id), + ); + Ok(FolderListingDto { + folders: folders_res?, + files: files_res?, + }) + } +} diff --git a/src/common/di.rs b/src/common/di.rs index 3334e63d..7fd5528e 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -19,6 +19,7 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService; use crate::application::services::recent_service::RecentService; use crate::application::services::search_service::SearchService; +use crate::application::services::share_browse_service::ShareBrowseService; use crate::application::services::share_service::ShareService; use crate::application::services::trash_service::TrashService; use crate::application::services::{ @@ -602,6 +603,15 @@ impl AppServiceFactory { let share_service = self.create_share_service(&repos, &pool); apps.share_service = share_service.clone(); + let share_browse_service = share_service.as_ref().map(|s| { + Arc::new(ShareBrowseService::new( + s.clone(), + apps.folder_service.clone(), + apps.file_retrieval_service.clone(), + repos.folder_repository.clone(), + )) + }); + // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; @@ -739,6 +749,7 @@ impl AppServiceFactory { migration_state: Arc::new(tokio::sync::RwLock::new(MigrationState::default())), trash_service, share_service, + share_browse_service, favorites_service, recent_service, storage_usage_service, @@ -1047,6 +1058,7 @@ pub struct AppState { pub migration_state: Arc>, pub trash_service: Option>, pub share_service: Option>, + pub share_browse_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, pub storage_usage_service: Option>, diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 0a641567..31e55bbd 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -190,4 +190,27 @@ pub trait FolderRepository: Send + Sync + 'static { matched.truncate(limit); Ok(matched) } + + /// `true` if `candidate_folder_id` is `root_folder_id` itself or any + /// (transitive) descendant. Default impl fails closed so stubs deny + /// access by default. + async fn is_folder_in_subtree( + &self, + candidate_folder_id: &str, + root_folder_id: &str, + ) -> Result { + let _ = (candidate_folder_id, root_folder_id); + Ok(false) + } + + /// `true` if `file_id`'s parent folder lies within the subtree rooted + /// at `root_folder_id`. + async fn is_file_in_subtree( + &self, + file_id: &str, + root_folder_id: &str, + ) -> Result { + let _ = (file_id, root_folder_id); + Ok(false) + } } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index ba346322..3743689a 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -965,6 +965,58 @@ impl FolderRepository for FolderDbRepository { }) .collect() } + + async fn is_folder_in_subtree( + &self, + candidate_folder_id: &str, + root_folder_id: &str, + ) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 \ + FROM storage.folders c, storage.folders r \ + WHERE c.id = $1::uuid \ + AND r.id = $2::uuid \ + AND c.is_trashed = false \ + AND r.is_trashed = false \ + AND c.lpath <@ r.lpath \ + )", + ) + .bind(candidate_folder_id) + .bind(root_folder_id) + .fetch_one(self.pool()) + .await + .map_err(|e| { + DomainError::internal_error("FolderDb", format!("is_folder_in_subtree: {e}")) + })?; + Ok(exists) + } + + async fn is_file_in_subtree( + &self, + file_id: &str, + root_folder_id: &str, + ) -> Result { + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (\ + SELECT 1 \ + FROM storage.files f \ + JOIN storage.folders parent ON f.folder_id = parent.id \ + JOIN storage.folders root ON root.id = $2::uuid \ + WHERE f.id = $1::uuid \ + AND f.is_trashed = false \ + AND parent.is_trashed = false \ + AND root.is_trashed = false \ + AND parent.lpath <@ root.lpath \ + )", + ) + .bind(file_id) + .bind(root_folder_id) + .fetch_one(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?; + Ok(exists) + } } // ── Extra helpers for blob-storage bootstrap ── diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 8ff0c570..5e68a609 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -967,56 +967,15 @@ impl FileHandler { /// Build a Content-Disposition header value. /// - /// Uses RFC 5987 `filename*=UTF-8''` to safely handle - /// filenames with quotes, non-ASCII characters, or other special chars. - /// A sanitised ASCII `filename=` fallback is included for legacy clients. + /// Build a `Content-Disposition` header value for an authenticated download, + /// honouring the `?inline=true|1` query param. Delegates to the shared + /// `build_content_disposition` so the share-link path produces identical + /// header values for the same `(name, mime)` pair. fn content_disposition(name: &str, mime: &str, params: &HashMap) -> String { let force_inline = params .get("inline") .is_some_and(|v| v == "true" || v == "1"); - let disposition = if force_inline - || mime.starts_with("image/") - || mime == "application/pdf" - || mime.starts_with("video/") - || mime.starts_with("audio/") - { - "inline" - } else { - "attachment" - }; - - // RFC 5987 percent-encode for filename* (attr-char safe set) - use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; - // Characters that DON'T need encoding per RFC 5987 attr-char: - // ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." / - // "^" / "_" / "`" / "|" / "~" - const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC - .remove(b'!') - .remove(b'#') - .remove(b'$') - .remove(b'&') - .remove(b'+') - .remove(b'-') - .remove(b'.') - .remove(b'^') - .remove(b'_') - .remove(b'`') - .remove(b'|') - .remove(b'~'); - let encoded = utf8_percent_encode(name, RFC5987_SET).to_string(); - - // ASCII fallback: strip anything outside printable ASCII and - // replace '"' and '\\' to prevent header injection. - let ascii_safe: String = name - .chars() - .filter(|c| c.is_ascii_graphic() || *c == ' ') - .map(|c| match c { - '"' | '\\' => '_', - _ => c, - }) - .collect(); - - format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}") + build_content_disposition(name, mime, force_inline) } /// Build a 201 Created JSON response. @@ -1073,6 +1032,49 @@ pub struct MoveFilePayload { pub folder_id: Option, } +/// RFC 5987-compliant `Content-Disposition` with both ASCII fallback and +/// `filename*=UTF-8''...` for non-ASCII filenames. +pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bool) -> String { + let disposition = if force_inline + || mime.starts_with("image/") + || mime == "application/pdf" + || mime.starts_with("video/") + || mime.starts_with("audio/") + { + "inline" + } else { + "attachment" + }; + + use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; + // RFC 5987 attr-char safe set (no encoding needed for these). + const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'!') + .remove(b'#') + .remove(b'$') + .remove(b'&') + .remove(b'+') + .remove(b'-') + .remove(b'.') + .remove(b'^') + .remove(b'_') + .remove(b'`') + .remove(b'|') + .remove(b'~'); + let encoded = utf8_percent_encode(name, RFC5987_SET).to_string(); + + let ascii_safe: String = name + .chars() + .filter(|c| c.is_ascii_graphic() || *c == ' ') + .map(|c| match c { + '"' | '\\' => '_', + _ => c, + }) + .collect(); + + format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}") +} + // ── Route handlers (free functions) ────────────────────────────────────────── // // All annotated route functions live here rather than as methods on FileHandler diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 34b699ed..6adca9b4 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -8,12 +8,15 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; +use http_range_header::parse_range_header; use serde::Deserialize; use serde_json::json; use utoipa::ToSchema; +use crate::application::services::share_browse_service::ZipTarget; use crate::application::services::share_service::ShareService; use crate::infrastructure::services::share_unlock_cookie; +use crate::interfaces::api::handlers::file_handler::build_content_disposition; use crate::{ application::{ dtos::share_dto::{CreateShareDto, UpdateShareDto}, @@ -27,6 +30,7 @@ use crate::{ interfaces::errors::AppError, interfaces::middleware::auth::AuthUser, }; +use tokio_util::io::ReaderStream; fn unlock_jwt_from_headers(headers: &HeaderMap, share_token: &str) -> Option { headers @@ -370,46 +374,380 @@ pub async fn download_shared_file( return AppError::bad_request("Download is only supported for file shares").into_response(); } - // 4. Retrieve file content via the internal (no-ownership-check) API + // 4. Stream the file with full Range / 304 / 416 / 206 support. + serve_share_file( + &state, + &share_dto.item_id, + share_dto.item_name.as_deref(), + &headers, + ) + .await +} + +/// Stream a file for a public share. Honours `If-None-Match` (304), +/// `Range` (206 / 416), and falls back to a 200 via `get_file_optimized`. +async fn serve_share_file( + state: &Arc, + file_id: &str, + name_override: Option<&str>, + request_headers: &HeaderMap, +) -> Response { let retrieval = &state.applications.file_retrieval_service; - let file_id = &share_dto.item_id; - match retrieval.get_file_optimized(file_id, false, true).await { - Ok((file_dto, content)) => { - let file_name = share_dto.item_name.as_deref().unwrap_or(&file_dto.name); - let disposition = format!( - "attachment; filename=\"{}\"", - file_name.replace('"', "\\\"") - ); - let mime = file_dto.mime_type.clone(); - - match content { - OptimizedFileContent::Bytes { data, .. } => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &*mime) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, data.len()) - .body(Body::from(data)) - .unwrap() - .into_response(), - OptimizedFileContent::Mmap(mmap_data) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &*mime) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, mmap_data.len()) - .body(Body::from(mmap_data)) - .unwrap() - .into_response(), - OptimizedFileContent::Stream(stream) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, &*mime) - .header(header::CONTENT_DISPOSITION, &disposition) - .header(header::CONTENT_LENGTH, file_dto.size) - .body(Body::from_stream(stream)) + let file_dto = match retrieval.get_file(file_id).await { + Ok(d) => d, + Err(err) => return AppError::from(err).into_response(), + }; + + let display_name = name_override.unwrap_or(&file_dto.name); + let etag = format!("\"{}-{}\"", file_dto.id, file_dto.modified_at); + let mime = file_dto.mime_type.clone(); + let disposition = build_content_disposition(display_name, &mime, false); + + if let Some(inm) = request_headers.get(header::IF_NONE_MATCH) + && let Ok(client_etag) = inm.to_str() + && (client_etag == etag || client_etag == "*") + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .body(Body::empty()) + .unwrap() + .into_response(); + } + + if let Some(range_hdr) = request_headers.get(header::RANGE) + && let Ok(range_str) = range_hdr.to_str() + && let Ok(ranges) = parse_range_header(range_str) + { + match ranges.validate(file_dto.size) { + Ok(valid_ranges) => { + if let Some(range) = valid_ranges.first() { + let start = *range.start(); + let end = *range.end(); + let length = end - start + 1; + + match retrieval + .get_file_range_stream(file_id, start, Some(end + 1)) + .await + { + Ok(stream) => { + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, &*mime) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, length) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_dto.size), + ) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .body(Body::from_stream(Box::into_pin(stream))) + .unwrap() + .into_response(); + } + Err(err) => { + tracing::error!("share range stream error: {}", err); + } + } + } + } + Err(_) => { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", file_dto.size)) + .body(Body::empty()) .unwrap() - .into_response(), + .into_response(); } } + } + + match retrieval.get_file_optimized(file_id, false, true).await { + Ok((_, content)) => match content { + OptimizedFileContent::Bytes { data, .. } => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &*mime) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, data.len()) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .body(Body::from(data)) + .unwrap() + .into_response(), + OptimizedFileContent::Mmap(mmap_data) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &*mime) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, mmap_data.len()) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .body(Body::from(mmap_data)) + .unwrap() + .into_response(), + OptimizedFileContent::Stream(stream) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &*mime) + .header(header::CONTENT_DISPOSITION, &disposition) + .header(header::CONTENT_LENGTH, file_dto.size) + .header(header::ACCEPT_RANGES, "bytes") + .header(header::ETAG, &etag) + .body(Body::from_stream(stream)) + .unwrap() + .into_response(), + }, Err(err) => AppError::from(err).into_response(), } } + +// ── Public folder browsing endpoints ────────────────────────────────────── + +fn sharing_disabled_response() -> Response { + AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Sharing is disabled", + "Disabled", + ) + .into_response() +} + +fn share_browse_error_response(err: crate::common::errors::DomainError) -> Response { + if err.kind == ErrorKind::AccessDenied { + if err.message.contains("password") { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "Password required", + "requiresPassword": true + })), + ) + .into_response(); + } + if err.message.contains("expired") { + return AppError::new(StatusCode::GONE, err.message, "Expired").into_response(); + } + } + AppError::from(err).into_response() +} + +#[utoipa::path( + get, + path = "/api/s/{token}/contents", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "Folder contents (sub-folders + files)"), + (status = 400, description = "Share is not a folder share"), + (status = 401, description = "Password required"), + (status = 410, description = "Share expired"), + (status = 503, description = "Sharing disabled") + ), + tag = "shares" +)] +pub async fn list_share_contents_root( + State(state): State>, + Path(token): Path, + headers: HeaderMap, +) -> impl IntoResponse { + let Some(browse) = state.share_browse_service.clone() else { + return sharing_disabled_response(); + }; + let unlock_jwt = unlock_jwt_from_headers(&headers, &token); + + match browse.list_root(&token, unlock_jwt.as_deref()).await { + Ok(listing) => (StatusCode::OK, Json(listing)).into_response(), + Err(err) => share_browse_error_response(err), + } +} + +#[utoipa::path( + get, + path = "/api/s/{token}/contents/{folder_id}", + params( + ("token" = String, Path, description = "Share token"), + ("folder_id" = String, Path, description = "Subfolder ID (must be inside the share)") + ), + responses( + (status = 200, description = "Subfolder contents"), + (status = 400, description = "Share is not a folder share"), + (status = 401, description = "Password required"), + (status = 404, description = "Subfolder not found or not in share scope"), + (status = 410, description = "Share expired") + ), + tag = "shares" +)] +pub async fn list_share_contents_subfolder( + State(state): State>, + Path((token, folder_id)): Path<(String, String)>, + headers: HeaderMap, +) -> impl IntoResponse { + let Some(browse) = state.share_browse_service.clone() else { + return sharing_disabled_response(); + }; + let unlock_jwt = unlock_jwt_from_headers(&headers, &token); + + match browse + .list_subfolder(&token, &folder_id, unlock_jwt.as_deref()) + .await + { + Ok(listing) => (StatusCode::OK, Json(listing)).into_response(), + Err(err) => share_browse_error_response(err), + } +} + +#[utoipa::path( + get, + path = "/api/s/{token}/file/{file_id}", + params( + ("token" = String, Path, description = "Share token"), + ("file_id" = String, Path, description = "File ID (must be inside the share)") + ), + responses( + (status = 200, description = "File content (or 206 for Range request)"), + (status = 206, description = "Partial Content"), + (status = 304, description = "Not Modified"), + (status = 401, description = "Password required"), + (status = 404, description = "File not found or not in share scope"), + (status = 410, description = "Share expired"), + (status = 416, description = "Range not satisfiable") + ), + tag = "shares" +)] +pub async fn download_share_file_in_folder( + State(state): State>, + Path((token, file_id)): Path<(String, String)>, + headers: HeaderMap, +) -> impl IntoResponse { + let Some(browse) = state.share_browse_service.clone() else { + return sharing_disabled_response(); + }; + let unlock_jwt = unlock_jwt_from_headers(&headers, &token); + + if let Err(err) = browse + .assert_file_in_share(&token, &file_id, unlock_jwt.as_deref()) + .await + { + return share_browse_error_response(err); + } + + serve_share_file(&state, &file_id, None, &headers).await +} + +#[utoipa::path( + get, + path = "/api/s/{token}/zip", + params(("token" = String, Path, description = "Share token")), + responses( + (status = 200, description = "ZIP archive of the shared folder"), + (status = 400, description = "Share is not a folder share"), + (status = 401, description = "Password required"), + (status = 410, description = "Share expired"), + (status = 503, description = "Sharing or ZIP service disabled") + ), + tag = "shares" +)] +pub async fn download_share_zip_root( + State(state): State>, + Path(token): Path, + headers: HeaderMap, +) -> impl IntoResponse { + serve_share_zip(state, token, None, headers).await +} + +#[utoipa::path( + get, + path = "/api/s/{token}/zip/{folder_id}", + params( + ("token" = String, Path, description = "Share token"), + ("folder_id" = String, Path, description = "Subfolder ID (must be inside the share)") + ), + responses( + (status = 200, description = "ZIP archive of the subfolder"), + (status = 401, description = "Password required"), + (status = 404, description = "Subfolder not found or not in share scope"), + (status = 410, description = "Share expired") + ), + tag = "shares" +)] +pub async fn download_share_zip_subfolder( + State(state): State>, + Path((token, folder_id)): Path<(String, String)>, + headers: HeaderMap, +) -> impl IntoResponse { + serve_share_zip(state, token, Some(folder_id), headers).await +} + +async fn serve_share_zip( + state: Arc, + token: String, + folder_id: Option, + headers: HeaderMap, +) -> Response { + let Some(browse) = state.share_browse_service.clone() else { + return sharing_disabled_response(); + }; + let zip_service = match &state.core.zip_service { + Some(svc) => svc, + None => { + return AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "ZIP service not initialized", + "Disabled", + ) + .into_response(); + } + }; + let unlock_jwt = unlock_jwt_from_headers(&headers, &token); + + let target: ZipTarget = match browse + .resolve_zip_target(&token, folder_id.as_deref(), unlock_jwt.as_deref()) + .await + { + Ok(t) => t, + Err(err) => return share_browse_error_response(err), + }; + + let temp_file = match zip_service + .create_folder_zip(&target.folder_id, &target.display_name) + .await + { + Ok(f) => f, + Err(err) => { + tracing::error!("share zip: create_folder_zip failed: {}", err); + return AppError::internal_error(format!("ZIP creation failed: {}", err)) + .into_response(); + } + }; + + let file_size = match temp_file.as_file().metadata() { + Ok(m) => m.len(), + Err(e) => { + tracing::error!("share zip: temp metadata failed: {}", e); + return AppError::internal_error("ZIP creation failed").into_response(); + } + }; + + // Reuse the existing fd: split off the std::File and the TempPath. + let (std_file, temp_path) = temp_file.into_parts(); + let tokio_file = tokio::fs::File::from_std(std_file); + let stream = ReaderStream::new(tokio_file); + let body = Body::from_stream(stream); + + let disposition = build_content_disposition( + &format!("{}.zip", target.display_name), + "application/zip", + false, + ); + + let mut response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/zip") + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CONTENT_LENGTH, file_size) + .body(body) + .unwrap(); + + // Keep TempPath alive until the body finishes streaming. + response.extensions_mut().insert(Arc::new(temp_path)); + response +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index d729b114..1d57a105 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -66,11 +66,32 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router { - // ── DOM refs ─────────────────────────────────────────────────── + // ── DOM refs ────────────────────────────────────────────────── const $loading = document.getElementById('share-loading'); const $password = document.getElementById('share-password'); const $expired = document.getElementById('share-expired'); @@ -19,26 +21,34 @@ const $fileName = document.getElementById('file-name'); const $fileMeta = document.getElementById('file-meta'); const $fileDl = document.getElementById('file-download'); - const $folderName = document.getElementById('folder-name'); const $expiredMsg = document.getElementById('expired-message'); - // ── Extract token from URL path (/s/{token}) ────────────────── + // ── Token from URL path (/s/{token}) ────────────────────────── const pathParts = window.location.pathname.split('/'); const tokenIdx = pathParts.indexOf('s'); const TOKEN = tokenIdx !== -1 ? pathParts[tokenIdx + 1] : null; - if (!TOKEN) { showState('expired'); - $expiredMsg.textContent = 'Invalid share link.'; + if ($expiredMsg) $expiredMsg.textContent = 'Invalid share link.'; return; } + const TOKEN_ENC = encodeURIComponent(TOKEN); + + // ── State management ────────────────────────────────────────── + const VIEW_KEY = 'oxi.share.view'; + let viewMode = 'grid'; + try { + viewMode = localStorage.getItem(VIEW_KEY) || 'grid'; + } catch (_) { + // localStorage unavailable + } + let rootDisplayName = 'Shared folder'; - // ── Helpers ──────────────────────────────────────────────────── function showState(name) { - [$loading, $password, $expired, $file, $folder].forEach((el) => { - el.classList.add('hidden'); - }); - var target = { + for (const el of [$loading, $password, $expired, $file, $folder]) { + if (el) el.classList.add('hidden'); + } + const target = { loading: $loading, password: $password, expired: $expired, @@ -46,24 +56,56 @@ folder: $folder }[name]; if (target) target.classList.remove('hidden'); + document.body.classList.toggle('gallery-mode', name === 'folder'); } + // ── Utilities ───────────────────────────────────────────────── + function escapeHtml(s) { + return String(s == null ? '' : s).replace( + /[&<>"']/g, + (c) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[c] + ); + } + function formatSize(bytes) { + if (bytes == null || Number.isNaN(bytes)) return ''; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; + } + function mediaKind(mime) { + const m = (mime || '').toLowerCase(); + if (m.startsWith('image/')) return 'image'; + if (m.startsWith('video/')) return 'video'; + return null; + } // ── Render share data ───────────────────────────────────────── function renderShare(data) { if (data.item_type === 'folder') { - $folderName.textContent = data.item_name || 'Shared Folder'; - showState('folder'); + rootDisplayName = data.item_name || 'Shared folder'; + initFolderGallery(); } else { $fileName.textContent = data.item_name || 'Shared File'; $fileMeta.textContent = data.item_name ? 'Shared file' : ''; - $fileDl.href = `/api/s/${TOKEN}/download`; + $fileDl.href = `/api/s/${TOKEN_ENC}/download`; showState('file'); } } - // ── Fetch share metadata ────────────────────────────────────── function fetchShare() { - fetch(`/api/s/${encodeURIComponent(TOKEN)}`) + fetch(`/api/s/${TOKEN_ENC}`) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { @@ -86,41 +128,376 @@ }) .catch(() => { showState('expired'); - $expiredMsg.textContent = 'This share link is no longer available.'; + if ($expiredMsg) { + $expiredMsg.textContent = 'This share link is no longer available.'; + } }); } - // ── Password form ───────────────────────────────────────────── - $pwForm.addEventListener('submit', (e) => { - e.preventDefault(); - $pwError.classList.add('hidden'); + if ($pwForm) { + $pwForm.addEventListener('submit', (e) => { + e.preventDefault(); + $pwError.classList.add('hidden'); + const password = $pwInput.value; + if (!password) return; + fetch(`/api/s/${TOKEN_ENC}/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }) + .then((res) => { + if (res.ok) return res.json(); + if (res.status === 401) { + $pwError.textContent = 'Incorrect password. Please try again.'; + $pwError.classList.remove('hidden'); + return null; + } + throw new Error(`HTTP ${res.status}`); + }) + .then((data) => { + if (data) renderShare(data); + }) + .catch(() => { + $pwError.textContent = 'An error occurred. Please try again.'; + $pwError.classList.remove('hidden'); + }); + }); + } + + // ── Folder gallery ──────────────────────────────────────────── - var password = $pwInput.value; - if (!password) return; + let currentFolderId = null; + let currentFolderName = null; - fetch(`/api/s/${encodeURIComponent(TOKEN)}/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: password }) - }) + function initFolderGallery() { + showState('folder'); + const hashFolderId = parseHashFolderId(); + if (hashFolderId) { + currentFolderId = hashFolderId; + currentFolderName = null; + loadAndRender(hashFolderId); + } else { + currentFolderId = null; + currentFolderName = null; + loadAndRender(null); + } + } + + function parseHashFolderId() { + const h = window.location.hash; + if (!h) return null; + const m = h.match(/[#&]folder=([A-Za-z0-9-]{1,64})/); + return m ? m[1] : null; + } + + function listingUrl(folderId) { + return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`; + } + function fileUrl(fileId) { + return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`; + } + function zipUrl(folderId) { + return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; + } + + function loadAndRender(folderId) { + $folder.innerHTML = ''; + fetch(listingUrl(folderId)) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { - $pwError.textContent = 'Incorrect password. Please try again.'; - $pwError.classList.remove('hidden'); + showState('password'); + return null; + } + if (res.status === 410 || res.status === 404) { + showState('expired'); return null; } throw new Error(`HTTP ${res.status}`); }) - .then((data) => { - if (data) renderShare(data); + .then((listing) => { + if (!listing) return; + renderGallery(listing, folderId); }) - .catch(() => { - $pwError.textContent = 'An error occurred. Please try again.'; - $pwError.classList.remove('hidden'); + .catch((err) => { + $folder.innerHTML = ''; + console.error('share gallery load failed:', err); + }); + } + + function renderGallery(listing, folderId) { + const isSubfolder = folderId !== null; + const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName; + const empty = (!listing.folders || listing.folders.length === 0) && (!listing.files || listing.files.length === 0); + + const backHtml = isSubfolder ? ' Back to share root' : ''; + + const headerHtml = ``; + + const foldersHtml = + listing.folders && listing.folders.length > 0 + ? `` + : ''; + + const filesHtml = + listing.files && listing.files.length > 0 + ? `` + : ''; + + const emptyHtml = empty ? '' : ''; + + $folder.innerHTML = headerHtml + emptyHtml + foldersHtml + filesHtml; + document.body.dataset.shareView = viewMode; + + wireGallery(); + wireLazyVideos(); + wireImageRetry(); + } + + function folderCardHtml(folder) { + return `
${escapeHtml(folder.name)}
Subfolder
`; + } + + function fileCardHtml(file) { + const url = fileUrl(file.id); + const kind = mediaKind(file.mime_type); + let thumbInner; + if (kind === 'image') { + thumbInner = ``; + } else if (kind === 'video') { + thumbInner = ``; + } else { + thumbInner = ``; + } + const mediaAttrs = kind ? ` data-mediakind="${kind}" data-src="${escapeHtml(url)}"` : ''; + const dataAttrs = ` data-id="${escapeHtml(file.id)}" data-name="${escapeHtml(file.name)}" data-mime="${escapeHtml(file.mime_type || '')}"${mediaAttrs}`; + return `
${thumbInner}
${escapeHtml(file.name)}
${escapeHtml(formatSize(file.size))}
`; + } + + function wireGallery() { + for (const btn of $folder.querySelectorAll('.gallery-view-toggle button')) { + btn.addEventListener('click', () => setViewMode(btn.dataset.view)); + } + const backBtn = $folder.querySelector('[data-action="back"]'); + if (backBtn) { + backBtn.addEventListener('click', (e) => { + e.preventDefault(); + navigate(null, rootDisplayName); + }); + } + for (const card of $folder.querySelectorAll('[data-action="open-folder"]')) { + card.addEventListener('click', (e) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + navigate(card.dataset.id, card.dataset.name); + }); + } + const mediaCards = Array.from($folder.querySelectorAll('.file-card[data-mediakind]')); + const items = mediaCards.map((el) => ({ + kind: el.dataset.mediakind, + src: el.dataset.src, + name: el.dataset.name + })); + mediaCards.forEach((el, i) => { + el.addEventListener('click', (e) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + openLightbox(items, i); }); + }); + } + + function setViewMode(v) { + const mode = v === 'list' ? 'list' : 'grid'; + viewMode = mode; + try { + localStorage.setItem(VIEW_KEY, mode); + } catch (_) { + // ignore + } + document.body.dataset.shareView = mode; + for (const b of $folder.querySelectorAll('.gallery-view-toggle button')) { + b.setAttribute('aria-pressed', String(b.dataset.view === mode)); + } + } + + function navigate(folderId, folderName) { + currentFolderId = folderId; + currentFolderName = folderName; + const hash = folderId ? `#folder=${encodeURIComponent(folderId)}` : ''; + history.pushState({ folderId, folderName }, '', window.location.pathname + hash); + loadAndRender(folderId); + } + + window.addEventListener('popstate', (e) => { + if (!$folder || $folder.classList.contains('hidden')) return; + const state = e.state || {}; + currentFolderId = state.folderId || parseHashFolderId(); + currentFolderName = state.folderName || null; + loadAndRender(currentFolderId); }); + // ── Lazy video posters + image retry ────────────────────────── + function wireLazyVideos() { + const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]'); + if (!lazy.length) return; + const start = (v) => { + v.addEventListener( + 'loadedmetadata', + () => { + const t = Math.min(0.1, (v.duration || 1) * 0.1); + try { + v.currentTime = t; + } catch (_) { + // unsupported + } + }, + { once: true } + ); + v.addEventListener( + 'error', + () => { + if (v.dataset.retried === '1') return; + v.dataset.retried = '1'; + const original = v.dataset.lazySrc; + setTimeout(() => { + const sep = original.indexOf('?') === -1 ? '?' : '&'; + v.src = `${original}${sep}_r=${Date.now()}`; + }, 250); + }, + { once: true } + ); + v.src = v.dataset.lazySrc; + }; + if ('IntersectionObserver' in window) { + const obs = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + if (e.target.dataset.lazySrc && !e.target.src) start(e.target); + obs.unobserve(e.target); + } + }, + { rootMargin: '300px' } + ); + for (const v of lazy) obs.observe(v); + } else { + for (const v of lazy) start(v); + } + } + + function wireImageRetry() { + for (const img of $folder.querySelectorAll('.file-thumb img')) { + img.addEventListener('error', () => { + if (img.dataset.retried === '1') return; + img.dataset.retried = '1'; + const original = img.src; + setTimeout(() => { + const sep = original.indexOf('?') === -1 ? '?' : '&'; + img.src = `${original}${sep}_r=${Date.now()}`; + }, 250); + }); + } + } + + // ── Lightbox ────────────────────────────────────────────────── + let lb = null; + let lbItems = []; + let lbIndex = -1; + + function ensureLightbox() { + if (lb) return lb; + const root = document.createElement('div'); + root.className = 'lightbox hidden'; + root.id = 'share-lightbox'; + root.setAttribute('role', 'dialog'); + root.innerHTML = + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '' + + '
'; + document.body.appendChild(root); + lb = { + root, + title: root.querySelector('.lb-title'), + download: root.querySelector('.lb-download'), + close: root.querySelector('.lb-close'), + stage: root.querySelector('.lb-stage'), + content: root.querySelector('.lb-content'), + prev: root.querySelector('.lb-prev'), + next: root.querySelector('.lb-next') + }; + lb.close.addEventListener('click', closeLightbox); + lb.prev.addEventListener('click', () => stepLightbox(-1)); + lb.next.addEventListener('click', () => stepLightbox(1)); + lb.root.addEventListener('click', (e) => { + if (e.target === lb.root) closeLightbox(); + }); + document.addEventListener('keydown', (e) => { + if (lb.root.classList.contains('hidden')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeLightbox(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + stepLightbox(-1); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + stepLightbox(1); + } + }); + return lb; + } + + function openLightbox(items, index) { + ensureLightbox(); + lbItems = items; + showLightboxItem(index); + } + function showLightboxItem(i) { + if (i < 0 || i >= lbItems.length) return; + lbIndex = i; + const item = lbItems[i]; + lb.content.innerHTML = ''; + if (item.kind === 'image') { + const img = document.createElement('img'); + img.src = item.src; + img.alt = item.name || ''; + lb.content.appendChild(img); + } else { + const v = document.createElement('video'); + v.src = item.src; + v.controls = true; + v.autoplay = true; + v.preload = 'metadata'; + lb.content.appendChild(v); + } + lb.title.textContent = item.name || ''; + lb.download.href = item.src; + if (item.name) lb.download.setAttribute('download', item.name); + lb.prev.disabled = i === 0; + lb.next.disabled = i === lbItems.length - 1; + lb.root.classList.remove('hidden'); + lb.root.setAttribute('aria-hidden', 'false'); + } + function stepLightbox(delta) { + const next = lbIndex + delta; + if (next >= 0 && next < lbItems.length) showLightboxItem(next); + } + function closeLightbox() { + if (!lb) return; + lb.root.classList.add('hidden'); + lb.root.setAttribute('aria-hidden', 'true'); + lb.content.innerHTML = ''; + lbIndex = -1; + } + // ── Init ────────────────────────────────────────────────────── fetchShare(); })(); diff --git a/static/share.html b/static/share.html index a8c15edc..309bdc30 100644 --- a/static/share.html +++ b/static/share.html @@ -5,6 +5,7 @@ OxiCloud — Shared + @@ -49,17 +50,11 @@

- - + + - +