diff --git a/build.rs b/build.rs index 56a9b798..6dfbb442 100644 --- a/build.rs +++ b/build.rs @@ -24,6 +24,7 @@ const HTML_INCLUDE: &[&str] = &[ "admin.html", "device-verify.html", "nextcloud-login.html", + "share.html", ]; // ─── View CSS files linked directly in index.html (not via @import) ────────── diff --git a/src/application/dtos/share_dto.rs b/src/application/dtos/share_dto.rs index a08b3e4b..9a527dec 100644 --- a/src/application/dtos/share_dto.rs +++ b/src/application/dtos/share_dto.rs @@ -46,7 +46,7 @@ pub struct UpdateShareDto { /// Extension methods to convert between DTOs and domain entities impl ShareDto { pub fn from_entity(share: &Share, base_url: &str) -> Self { - let url = format!("{}/api/s/{}", base_url, share.token()); + let url = format!("{}/s/{}", base_url, share.token()); Self { id: share.id().to_string(), diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index d9e26d13..14e25800 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -1135,6 +1135,6 @@ mod tests { assert_eq!(share_dto.item_id, "test_file_id"); assert_eq!(share_dto.item_type, "file"); assert!(share_dto.has_password); - assert!(share_dto.url.starts_with("http://127.0.0.1:8086/api/s/")); + assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/")); } } diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs index 1ecb92e1..0b732583 100644 --- a/src/interfaces/api/handlers/share_handler.rs +++ b/src/interfaces/api/handlers/share_handler.rs @@ -3,9 +3,10 @@ use uuid::Uuid; use axum::{ Json, + body::Body, extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, + http::{StatusCode, header}, + response::{IntoResponse, Response}, }; use serde::Deserialize; use serde_json::json; @@ -15,9 +16,9 @@ use crate::application::services::share_service::ShareService; use crate::{ application::{ dtos::share_dto::{CreateShareDto, UpdateShareDto}, - ports::share_ports::ShareUseCase, + ports::{file_ports::{FileRetrievalUseCase, OptimizedFileContent}, share_ports::ShareUseCase}, }, - common::errors::ErrorKind, + common::{di::AppState, errors::ErrorKind}, domain::entities::share::ShareItemType, interfaces::errors::AppError, interfaces::middleware::auth::AuthUser, @@ -271,3 +272,96 @@ pub async fn verify_shared_item_password( } } } + +/// Download the actual file content for a shared file via its token. +/// +/// Validates the share token, checks it refers to a file (not folder), +/// then streams the file content to the caller. +pub async fn download_shared_file( + State(state): State>, + Path(token): Path, +) -> impl IntoResponse { + // 1. Resolve share service + let share_service = match &state.share_service { + Some(s) => s.clone(), + None => { + return AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "Sharing is disabled", + "Disabled", + ) + .into_response() + } + }; + + // 2. Validate the share token (handles expiry + password checks) + let share_dto = match share_service.get_shared_link_by_token(&token).await { + Ok(dto) => dto, + Err(err) => { + 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(); + } + } + return AppError::from(err).into_response(); + } + }; + + // 3. Only file shares support direct download + if share_dto.item_type != "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 + 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)) + .unwrap() + .into_response(), + } + } + Err(err) => AppError::from(err).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index ff343816..062e74cd 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -51,6 +51,12 @@ pub fn create_public_api_routes(app_state: &Arc) -> Router Router> { .route("/profile", get(serve_profile_page)) .route("/admin", get(serve_admin_page)) .route("/device", get(serve_device_verify_page)) + .route("/s/{token}", get(serve_share_page)) // Serve static files with compression + cache headers .fallback_service(static_service) .layer(CompressionLayer::new().br(true).gzip(true)) @@ -90,3 +91,8 @@ async fn serve_device_verify_page() -> Html<&'static str> { "/device-verify.html" ))) } + +/// Serve the public share page (unauthenticated) +async fn serve_share_page() -> Html<&'static str> { + Html(include_str!(concat!(env!("OUT_DIR"), "/share.html"))) +} diff --git a/static/css/views/share-public.css b/static/css/views/share-public.css new file mode 100644 index 00000000..3eb835f9 --- /dev/null +++ b/static/css/views/share-public.css @@ -0,0 +1,132 @@ +/* share-public.css — stand-alone styles for the public share page */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--color-bg-hover); + color: var(--color-text-heading); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 1rem; +} + +.share-card { + background: var(--color-bg-surface); + border-radius: var(--radius, 12px); + box-shadow: 0 4px 24px var(--color-shadow-sm); + padding: 2.5rem; + max-width: 480px; + width: 100%; + text-align: center; +} + +.share-logo { + margin-bottom: 1.5rem; +} +.share-logo h1 { + font-size: 1.5rem; + font-weight: 700; +} +.share-logo span { + color: var(--color-primary); +} + +/* States */ +.share-state { } +.hidden { display: none !important; } + +h2 { + font-size: 1.15rem; + margin-bottom: 0.5rem; +} +p.subtitle { + color: var(--color-text-gray); + font-size: 0.9rem; + margin-bottom: 1.25rem; +} + +/* Spinner */ +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + margin: 0 auto 1rem; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Icons */ +.share-icon { + font-size: 3rem; + margin-bottom: 0.75rem; +} + +/* Password form */ +#password-form { + text-align: left; +} +#password-input { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid var(--color-border); + border-radius: 8px; + outline: none; + background: var(--color-bg-surface); + color: var(--color-text-heading); + transition: border-color 0.2s; + margin-bottom: 0.75rem; +} +#password-input:focus { + border-color: var(--color-primary); +} +.error-text { + color: var(--color-error, #e53935); + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +/* Primary button */ +.btn-primary { + display: inline-block; + width: 100%; + padding: 0.75rem 1.5rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + text-align: center; + transition: background 0.2s; +} +.btn-primary:hover { + opacity: 0.9; +} + +/* File view */ +#file-name { + word-break: break-word; +} +#file-meta { + font-size: 0.85rem; +} + +/* Folder view */ +.folder-list { + text-align: left; + margin-top: 1rem; + max-height: 400px; + overflow-y: auto; +} + +/* Responsive */ +@media (max-width: 500px) { + .share-card { padding: 1.5rem; } +} diff --git a/static/js/views/public/publicShare.js b/static/js/views/public/publicShare.js new file mode 100644 index 00000000..3783c80f --- /dev/null +++ b/static/js/views/public/publicShare.js @@ -0,0 +1,139 @@ +/** + * publicShare.js — Client-side logic for the public share page (/s/{token}). + * + * Fetches share metadata from the API, handles password-protected shares, + * and renders file download or folder info. + */ +(function () { + 'use strict'; + + // ── DOM refs ─────────────────────────────────────────────────── + const $loading = document.getElementById('share-loading'); + const $password = document.getElementById('share-password'); + const $expired = document.getElementById('share-expired'); + const $file = document.getElementById('share-file'); + const $folder = document.getElementById('share-folder'); + + const $pwForm = document.getElementById('password-form'); + const $pwInput = document.getElementById('password-input'); + const $pwError = document.getElementById('password-error'); + + 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}) ────────────────── + 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.'; + return; + } + + // ── Helpers ──────────────────────────────────────────────────── + function showState(name) { + [$loading, $password, $expired, $file, $folder].forEach(function (el) { + el.classList.add('hidden'); + }); + var target = { + loading: $loading, + password: $password, + expired: $expired, + file: $file, + folder: $folder, + }[name]; + if (target) target.classList.remove('hidden'); + } + + function formatSize(bytes) { + if (!bytes || bytes === 0) return ''; + var units = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = 0; + var size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; + } + + // ── Render share data ───────────────────────────────────────── + function renderShare(data) { + if (data.item_type === 'folder') { + $folderName.textContent = data.item_name || 'Shared Folder'; + showState('folder'); + } else { + $fileName.textContent = data.item_name || 'Shared File'; + $fileMeta.textContent = data.item_name + ? 'Shared file' + : ''; + $fileDl.href = '/api/s/' + TOKEN + '/download'; + showState('file'); + } + } + + // ── Fetch share metadata ────────────────────────────────────── + function fetchShare() { + fetch('/api/s/' + encodeURIComponent(TOKEN)) + .then(function (res) { + if (res.ok) return res.json(); + if (res.status === 401) { + return res.json().then(function (body) { + if (body && body.requiresPassword) { + showState('password'); + return null; + } + throw new Error('Unauthorized'); + }); + } + if (res.status === 410) { + showState('expired'); + return null; + } + throw new Error('HTTP ' + res.status); + }) + .then(function (data) { + if (data) renderShare(data); + }) + .catch(function () { + showState('expired'); + $expiredMsg.textContent = 'This share link is no longer available.'; + }); + } + + // ── Password form ───────────────────────────────────────────── + $pwForm.addEventListener('submit', function (e) { + e.preventDefault(); + $pwError.classList.add('hidden'); + + var password = $pwInput.value; + if (!password) return; + + fetch('/api/s/' + encodeURIComponent(TOKEN) + '/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: password }), + }) + .then(function (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(function (data) { + if (data) renderShare(data); + }) + .catch(function () { + $pwError.textContent = 'An error occurred. Please try again.'; + $pwError.classList.remove('hidden'); + }); + }); + + // ── Init ────────────────────────────────────────────────────── + fetchShare(); +})(); diff --git a/static/share.html b/static/share.html new file mode 100644 index 00000000..a8c15edc --- /dev/null +++ b/static/share.html @@ -0,0 +1,65 @@ + + + + + + OxiCloud — Shared + + + + + + + + + +