Merge pull request #347 from abnvle/feat/share-folder-browsing
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<ShareService>,
|
||||
folder_service: Arc<FolderService>,
|
||||
file_retrieval: Arc<FileRetrievalService>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
}
|
||||
|
||||
impl ShareBrowseService {
|
||||
pub fn new(
|
||||
share_service: Arc<ShareService>,
|
||||
folder_service: Arc<FolderService>,
|
||||
file_retrieval: Arc<FileRetrievalService>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
share_service,
|
||||
folder_service,
|
||||
file_retrieval,
|
||||
folder_repo,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_folder_share(
|
||||
&self,
|
||||
token: &str,
|
||||
unlock_jwt: Option<&str>,
|
||||
) -> Result<ResolvedFolderShare, DomainError> {
|
||||
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<FolderListingDto, DomainError> {
|
||||
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<FolderListingDto, DomainError> {
|
||||
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<ZipTarget, DomainError> {
|
||||
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<FolderListingDto, DomainError> {
|
||||
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?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<Arc<FavoritesService>>;
|
||||
let recent_service: Option<Arc<RecentService>>;
|
||||
@@ -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<tokio::sync::RwLock<MigrationState>>,
|
||||
pub trash_service: Option<Arc<TrashService>>,
|
||||
pub share_service: Option<Arc<ShareService>>,
|
||||
pub share_browse_service: Option<Arc<ShareBrowseService>>,
|
||||
pub favorites_service: Option<Arc<FavoritesService>>,
|
||||
pub recent_service: Option<Arc<RecentService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
|
||||
@@ -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<bool, DomainError> {
|
||||
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<bool, DomainError> {
|
||||
let _ = (file_id, root_folder_id);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,6 +965,71 @@ impl FolderRepository for FolderDbRepository {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn is_folder_in_subtree(
|
||||
&self,
|
||||
candidate_folder_id: &str,
|
||||
root_folder_id: &str,
|
||||
) -> Result<bool, DomainError> {
|
||||
let (Ok(candidate_uuid), Ok(root_uuid)) = (
|
||||
Uuid::parse_str(candidate_folder_id),
|
||||
Uuid::parse_str(root_folder_id),
|
||||
) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (\
|
||||
SELECT 1 \
|
||||
FROM storage.folders c, storage.folders r \
|
||||
WHERE c.id = $1 \
|
||||
AND r.id = $2 \
|
||||
AND c.is_trashed = false \
|
||||
AND r.is_trashed = false \
|
||||
AND c.lpath <@ r.lpath \
|
||||
)",
|
||||
)
|
||||
.bind(candidate_uuid)
|
||||
.bind(root_uuid)
|
||||
.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<bool, DomainError> {
|
||||
let (Ok(file_uuid), Ok(root_uuid)) =
|
||||
(Uuid::parse_str(file_id), Uuid::parse_str(root_folder_id))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
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 \
|
||||
WHERE f.id = $1 \
|
||||
AND f.is_trashed = false \
|
||||
AND parent.is_trashed = false \
|
||||
AND root.is_trashed = false \
|
||||
AND parent.lpath <@ root.lpath \
|
||||
)",
|
||||
)
|
||||
.bind(file_uuid)
|
||||
.bind(root_uuid)
|
||||
.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 ──
|
||||
|
||||
@@ -967,56 +967,15 @@ impl FileHandler {
|
||||
|
||||
/// Build a Content-Disposition header value.
|
||||
///
|
||||
/// Uses RFC 5987 `filename*=UTF-8''<percent-encoded>` 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, String>) -> 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<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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<String> {
|
||||
headers
|
||||
@@ -370,46 +374,414 @@ 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<AppState>,
|
||||
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();
|
||||
let file_dto = match retrieval.get_file(file_id).await {
|
||||
Ok(d) => d,
|
||||
Err(err) => return AppError::from(err).into_response(),
|
||||
};
|
||||
|
||||
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))
|
||||
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)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.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)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.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))
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.body(Body::empty())
|
||||
.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(),
|
||||
.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)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.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)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.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)
|
||||
.header(
|
||||
header::CACHE_CONTROL,
|
||||
"private, max-age=3600, must-revalidate",
|
||||
)
|
||||
.header(header::VARY, "Cookie, Range")
|
||||
.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<Arc<AppState>>,
|
||||
Path(token): Path<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_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"),
|
||||
(status = 503, description = "Sharing disabled")
|
||||
),
|
||||
tag = "shares"
|
||||
)]
|
||||
pub async fn list_share_contents_subfolder(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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<Arc<AppState>>,
|
||||
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<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
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"),
|
||||
(status = 503, description = "Sharing or ZIP service disabled")
|
||||
),
|
||||
tag = "shares"
|
||||
)]
|
||||
pub async fn download_share_zip_subfolder(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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<AppState>,
|
||||
token: String,
|
||||
folder_id: Option<String>,
|
||||
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)
|
||||
.header(header::CACHE_CONTROL, "private, no-store")
|
||||
.header(header::VARY, "Cookie")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
// Keep TempPath alive until the body finishes streaming.
|
||||
response.extensions_mut().insert(Arc::new(temp_path));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -104,11 +104,32 @@ pub fn create_public_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppStat
|
||||
|
||||
router = router.nest("/s", public_share_router);
|
||||
|
||||
// Download endpoint uses full AppState (needs FileRetrievalService)
|
||||
router = router.route(
|
||||
"/s/{token}/download",
|
||||
get(share_handler::download_shared_file),
|
||||
);
|
||||
// AppState-backed share endpoints (download, contents, file, zip)
|
||||
router = router
|
||||
.route(
|
||||
"/s/{token}/download",
|
||||
get(share_handler::download_shared_file),
|
||||
)
|
||||
.route(
|
||||
"/s/{token}/contents",
|
||||
get(share_handler::list_share_contents_root),
|
||||
)
|
||||
.route(
|
||||
"/s/{token}/contents/{folder_id}",
|
||||
get(share_handler::list_share_contents_subfolder),
|
||||
)
|
||||
.route(
|
||||
"/s/{token}/file/{file_id}",
|
||||
get(share_handler::download_share_file_in_folder),
|
||||
)
|
||||
.route(
|
||||
"/s/{token}/zip",
|
||||
get(share_handler::download_share_zip_root),
|
||||
)
|
||||
.route(
|
||||
"/s/{token}/zip/{folder_id}",
|
||||
get(share_handler::download_share_zip_subfolder),
|
||||
);
|
||||
}
|
||||
|
||||
// i18n routes — no auth required (localization should be available before login)
|
||||
|
||||
@@ -118,6 +118,13 @@
|
||||
--color-overlay-light: rgba(0, 0, 0, 0.45);
|
||||
--color-overlay-heavy: rgba(0, 0, 0, 0.85);
|
||||
--color-overlay-darkest: rgba(0, 0, 0, 0.92);
|
||||
--color-overlay-shadow: rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Foreground / control surfaces on top of dark overlays */
|
||||
--color-on-overlay: rgba(255, 255, 255, 0.95);
|
||||
--color-on-overlay-muted: rgba(255, 255, 255, 0.9);
|
||||
--color-overlay-button: rgba(255, 255, 255, 0.12);
|
||||
--color-overlay-button-hover: rgba(255, 255, 255, 0.22);
|
||||
|
||||
/* Items */
|
||||
--color-item: var(--color-bg-surface);
|
||||
|
||||
@@ -144,3 +144,391 @@ FIXME: use components/buttons.css ?
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Folder gallery ────────────────────────────────────────────── */
|
||||
body.gallery-mode {
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
body.gallery-mode .share-card {
|
||||
max-width: 1100px;
|
||||
padding: 1.75rem 1.5rem 2.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
body.gallery-mode .share-logo {
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#share-folder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-loading,
|
||||
.gallery-empty,
|
||||
.gallery-error {
|
||||
text-align: center;
|
||||
color: var(--color-text-gray);
|
||||
padding: 3rem 1rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius, 12px);
|
||||
}
|
||||
.gallery-error {
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.gallery-title {
|
||||
flex: 1 1 200px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
.gallery-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gallery-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-heading);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.gallery-back:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.gallery-view-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 3px;
|
||||
}
|
||||
.gallery-view-toggle button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-gray);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.gallery-view-toggle button[aria-pressed="true"] {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg-surface);
|
||||
}
|
||||
.gallery-zip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 999px;
|
||||
width: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gallery-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-gray);
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-folders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.folder-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-heading);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
.folder-card:hover {
|
||||
background: var(--color-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.folder-card .folder-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.folder-card .card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.folder-card .card-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-gray);
|
||||
}
|
||||
|
||||
.gallery-files {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.file-card {
|
||||
display: block;
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
color: var(--color-text-heading);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.file-card:hover {
|
||||
background: var(--color-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px -10px var(--color-shadow-sm);
|
||||
}
|
||||
.file-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--color-bg-surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-thumb img,
|
||||
.file-thumb video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.file-thumb video {
|
||||
pointer-events: none;
|
||||
background: var(--color-black);
|
||||
}
|
||||
.file-thumb .file-type-icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-gray);
|
||||
}
|
||||
.file-thumb .video-marker {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.file-thumb .video-marker::before {
|
||||
content: "▶";
|
||||
color: var(--color-on-overlay);
|
||||
font-size: 28px;
|
||||
text-shadow: 0 2px 8px var(--color-overlay-shadow);
|
||||
}
|
||||
.file-card .card-body {
|
||||
padding: 0.5rem 0.7rem 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-card .card-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-card .card-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-gray);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── List view ───────────────────────────────────────────────── */
|
||||
body[data-share-view="list"] .gallery-files {
|
||||
display: block;
|
||||
}
|
||||
body[data-share-view="list"] .file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
body[data-share-view="list"] .file-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
body[data-share-view="list"] .file-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
body[data-share-view="list"] .file-thumb .file-type-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
body[data-share-view="list"] .file-thumb .video-marker::before {
|
||||
font-size: 14px;
|
||||
}
|
||||
body[data-share-view="list"] .file-card .card-body {
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Lightbox ──────────────────────────────────────────────────── */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay-darkest);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
animation: lb-fade 180ms ease;
|
||||
}
|
||||
.lightbox.hidden {
|
||||
display: none;
|
||||
}
|
||||
@keyframes lb-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.lb-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--color-on-overlay-muted);
|
||||
}
|
||||
.lb-title {
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lb-bar a,
|
||||
.lb-bar button {
|
||||
background: var(--color-overlay-button);
|
||||
color: var(--color-on-overlay);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.lb-bar a:hover,
|
||||
.lb-bar button:hover {
|
||||
background: var(--color-overlay-button-hover);
|
||||
}
|
||||
.lb-stage {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 1rem 1.5rem;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lb-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.lb-content img,
|
||||
.lb-content video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--color-black);
|
||||
box-shadow: 0 20px 60px var(--color-overlay-shadow);
|
||||
}
|
||||
.lb-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--color-overlay-button);
|
||||
color: var(--color-on-overlay);
|
||||
border: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.lb-nav:hover {
|
||||
background: var(--color-overlay-button-hover);
|
||||
}
|
||||
.lb-nav:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
.lb-prev {
|
||||
left: 1rem;
|
||||
}
|
||||
.lb-next {
|
||||
right: 1rem;
|
||||
}
|
||||
.lb-nav .oxi-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { oxiIconsInit } from '../../core/icons';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* publicShare.js — client-side logic for /s/{token}.
|
||||
* Drives the password / file / folder / expired states and renders the
|
||||
* folder gallery against /api/s/{token}/contents and friends.
|
||||
*/
|
||||
|
||||
import { uiFileTypes } from '../../app/uiFileTypes.js';
|
||||
|
||||
(() => {
|
||||
// ── DOM refs ───────────────────────────────────────────────────
|
||||
// ── DOM refs ──────────────────────────────────────────────────
|
||||
const $loading = document.getElementById('share-loading');
|
||||
const $password = document.getElementById('share-password');
|
||||
const $expired = document.getElementById('share-expired');
|
||||
@@ -21,10 +23,9 @@ import { oxiIconsInit } from '../../core/icons';
|
||||
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;
|
||||
@@ -33,16 +34,26 @@ import { oxiIconsInit } from '../../core/icons';
|
||||
|
||||
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,
|
||||
@@ -50,24 +61,56 @@ import { oxiIconsInit } from '../../core/icons';
|
||||
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) {
|
||||
@@ -90,41 +133,382 @@ import { oxiIconsInit } from '../../core/icons';
|
||||
})
|
||||
.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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var password = $pwInput.value;
|
||||
if (!password) return;
|
||||
// ── Folder gallery ────────────────────────────────────────────
|
||||
|
||||
fetch(`/api/s/${encodeURIComponent(TOKEN)}/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password })
|
||||
})
|
||||
let currentFolderId = null;
|
||||
let currentFolderName = null;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
let currentLoadController = null;
|
||||
function loadAndRender(folderId) {
|
||||
if (currentLoadController) currentLoadController.abort();
|
||||
const controller = new AbortController();
|
||||
currentLoadController = controller;
|
||||
|
||||
$folder.innerHTML = '<div class="gallery-loading"><div class="spinner"></div></div>';
|
||||
fetch(listingUrl(folderId), { signal: controller.signal })
|
||||
.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 (controller.signal.aborted || !listing) return;
|
||||
renderGallery(listing, folderId);
|
||||
})
|
||||
.catch(() => {
|
||||
$pwError.textContent = 'An error occurred. Please try again.';
|
||||
$pwError.classList.remove('hidden');
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
$folder.innerHTML = '<div class="gallery-error">Failed to load contents. Try again.</div>';
|
||||
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 ? '<a class="gallery-back" href="#" data-action="back"><i class="fas fa-arrow-left"></i> Back to share root</a>' : '';
|
||||
|
||||
const headerHtml = `<header class="gallery-header"><h2 class="gallery-title">${escapeHtml(title)}</h2><div class="gallery-actions">${backHtml}<div class="gallery-view-toggle" role="group"><button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th-large"></i></button><button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button></div><a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download><i class="fas fa-file-archive"></i> Download ZIP</a></div></header>`;
|
||||
|
||||
const foldersHtml =
|
||||
listing.folders && listing.folders.length > 0
|
||||
? `<h3 class="gallery-section-title">Folders</h3><div class="gallery-folders">${listing.folders.map(folderCardHtml).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const filesHtml =
|
||||
listing.files && listing.files.length > 0
|
||||
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((f) => fileCardHtml(f)).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : '';
|
||||
|
||||
$folder.innerHTML = headerHtml + emptyHtml + foldersHtml + filesHtml;
|
||||
document.body.dataset.shareView = viewMode;
|
||||
|
||||
wireGallery();
|
||||
wireLazyVideos();
|
||||
wireImageRetry();
|
||||
}
|
||||
|
||||
function folderCardHtml(folder) {
|
||||
return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`;
|
||||
}
|
||||
|
||||
function fileCardHtml(file) {
|
||||
const url = fileUrl(file.id);
|
||||
const kind = mediaKind(file.mime_type);
|
||||
let thumbInner;
|
||||
if (kind === 'image') {
|
||||
thumbInner = `<img src="${escapeHtml(url)}" alt="" decoding="async" />`;
|
||||
} else if (kind === 'video') {
|
||||
thumbInner = `<video data-lazy-src="${escapeHtml(url)}" preload="metadata" muted playsinline disablepictureinpicture></video><span class="video-marker" aria-hidden="true"></span>`;
|
||||
} else {
|
||||
thumbInner = `<i class="${escapeHtml(uiFileTypes.getIconClass(file.name))} file-type-icon"></i>`;
|
||||
}
|
||||
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 `<a class="file-card" href="${escapeHtml(url)}"${dataAttrs}><div class="file-thumb">${thumbInner}</div><div class="card-body"><div class="card-name">${escapeHtml(file.name)}</div><div class="card-meta">${escapeHtml(formatSize(file.size))}</div></div></a>`;
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="lb-bar">' +
|
||||
'<span class="lb-title"></span>' +
|
||||
'<a class="lb-download" href="#" download title="Download"><i class="fas fa-download"></i></a>' +
|
||||
'<button class="lb-close" type="button" title="Close"><i class="fas fa-times"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="lb-stage">' +
|
||||
'<button class="lb-nav lb-prev" type="button" aria-label="Previous"><i class="fas fa-chevron-left"></i></button>' +
|
||||
'<div class="lb-content"></div>' +
|
||||
'<button class="lb-nav lb-next" type="button" aria-label="Next"><i class="fas fa-chevron-right"></i></button>' +
|
||||
'</div>';
|
||||
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();
|
||||
})();
|
||||
|
||||
+4
-9
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OxiCloud — Shared</title>
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<script type="module" src="/js/core/icons.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/share-public.css">
|
||||
</head>
|
||||
@@ -49,17 +50,11 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Folder share view -->
|
||||
<div id="share-folder" class="share-state hidden">
|
||||
<h2 id="folder-name"></h2>
|
||||
<p class="subtitle" id="folder-meta">Shared folder</p>
|
||||
<div id="folder-contents" class="folder-list">
|
||||
<p class="subtitle">Folder browsing is not yet available. Use a WebDAV client for full access.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Folder share view: populated by publicShare.js -->
|
||||
<div id="share-folder" class="share-state hidden"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/views/public/publicShare.js"></script>
|
||||
<script type="module" src="/js/views/public/publicShare.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user