Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d677f92b7f | |||
| 5f9f91ee2f | |||
| 6ee26e46e6 |
@@ -9,6 +9,16 @@ import type { ItemType } from '$lib/api/types';
|
|||||||
export interface ShareMeta {
|
export interface ShareMeta {
|
||||||
item_type: ItemType;
|
item_type: ItemType;
|
||||||
item_name: string;
|
item_name: string;
|
||||||
|
/** The shared item's id — file shares use it to build the preview src. */
|
||||||
|
item_id: string;
|
||||||
|
/**
|
||||||
|
* File shares only: resolved by the server at read time so the landing
|
||||||
|
* page can inline a media preview (video player / image) instead of a
|
||||||
|
* bare download button. Absent for folder shares.
|
||||||
|
*/
|
||||||
|
mime_type?: string;
|
||||||
|
/** File shares only: the shared file's size in bytes. */
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShareFolderEntry {
|
export interface ShareFolderEntry {
|
||||||
|
|||||||
@@ -287,7 +287,29 @@
|
|||||||
</form>
|
</form>
|
||||||
{:else if view === 'file'}
|
{:else if view === 'file'}
|
||||||
<div class="share__center">
|
<div class="share__center">
|
||||||
<Icon name="file" class="share__big-icon" />
|
{#if meta && mediaKind(meta.mime_type) === 'video'}
|
||||||
|
<!-- Inline player: Range-aware endpoint streams the video, so the
|
||||||
|
timeline seeks without downloading the whole file first.
|
||||||
|
lazyVideo defers the load, seeks a few frames in for a poster
|
||||||
|
and retries once on error (same behaviour as the folder grid). -->
|
||||||
|
<video
|
||||||
|
class="share__media"
|
||||||
|
data-testid="public-share-file-video"
|
||||||
|
use:lazyVideo={shareFileUrl(token, meta.item_id)}
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
|
{:else if meta && mediaKind(meta.mime_type) === 'image'}
|
||||||
|
<img
|
||||||
|
class="share__media"
|
||||||
|
data-testid="public-share-file-image"
|
||||||
|
src={shareFileUrl(token, meta.item_id)}
|
||||||
|
alt={meta.item_name}
|
||||||
|
use:imageRetry
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon name="file" class="share__big-icon" />
|
||||||
|
{/if}
|
||||||
<h1>{meta?.item_name}</h1>
|
<h1>{meta?.item_name}</h1>
|
||||||
<a
|
<a
|
||||||
class="share__btn"
|
class="share__btn"
|
||||||
@@ -486,6 +508,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share__media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(70vh, 40rem);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.share__big-icon) {
|
:global(.share__big-icon) {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ pub struct ShareDto {
|
|||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub access_count: u64,
|
pub access_count: u64,
|
||||||
|
/// File shares only: the shared file's MIME type, resolved at read time
|
||||||
|
/// so anonymous viewers can render an inline media preview (video player
|
||||||
|
/// / image) instead of a bare download button. Absent for folder shares
|
||||||
|
/// and whenever the file lookup fails (display-only enrichment).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
/// File shares only: the shared file's size in bytes (see `mime_type`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -49,6 +58,8 @@ impl ShareDto {
|
|||||||
created_at: share.created_at(),
|
created_at: share.created_at(),
|
||||||
created_by: share.created_by().to_string(),
|
created_by: share.created_by().to_string(),
|
||||||
access_count: share.access_count(),
|
access_count: share.access_count(),
|
||||||
|
mime_type: None,
|
||||||
|
size: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,18 @@ impl ShareService {
|
|||||||
};
|
};
|
||||||
self.fetch_share_resolved(token, unlocked).await
|
self.fetch_share_resolved(token, unlocked).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public share landing payload: share metadata enriched with the shared
|
||||||
|
/// file's `mime_type` + `size` so anonymous viewers get an inline media
|
||||||
|
/// preview (video player / image) instead of a bare download button.
|
||||||
|
pub async fn get_shared_link_meta_with_unlock(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
unlock_jwt: Option<&str>,
|
||||||
|
) -> Result<ShareDto, DomainError> {
|
||||||
|
let dto = self.get_shared_link_with_unlock(token, unlock_jwt).await?;
|
||||||
|
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShareUseCase for ShareService {
|
impl ShareUseCase for ShareService {
|
||||||
@@ -538,7 +550,8 @@ impl ShareUseCase for ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Password verified (or not required) — return full share metadata
|
// Password verified (or not required) — return full share metadata
|
||||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
let dto = ShareDto::from_entity(&share, &self.base_url);
|
||||||
|
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||||
@@ -558,6 +571,31 @@ impl ShareUseCase for ShareService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill `mime_type`/`size` on file-share DTOs so anonymous viewers get an
|
||||||
|
/// inline media preview (video player / image) on the public share page
|
||||||
|
/// instead of a bare download button.
|
||||||
|
///
|
||||||
|
/// Display-only enrichment that never fails the response: a failed file
|
||||||
|
/// lookup (transient DB error, race with a delete) leaves the fields `None`
|
||||||
|
/// and the download endpoint surfaces the real error — a read failure is
|
||||||
|
/// never proof the data is absent. Folder shares pass through untouched.
|
||||||
|
///
|
||||||
|
/// A free function (not a method) so the `integration_tests` mirror of the
|
||||||
|
/// service exercises the exact same logic instead of re-implementing it.
|
||||||
|
async fn enrich_share_dto_with_file_info<FR: FileReadPort>(
|
||||||
|
mut dto: ShareDto,
|
||||||
|
file_repository: &FR,
|
||||||
|
) -> ShareDto {
|
||||||
|
if dto.item_type != "file" {
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
if let Ok(file) = file_repository.get_file(&dto.item_id).await {
|
||||||
|
dto.mime_type = Some(file.mime_type().to_string());
|
||||||
|
dto.size = Some(file.size());
|
||||||
|
}
|
||||||
|
dto
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "integration_tests")]
|
#[cfg(feature = "integration_tests")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -642,6 +680,18 @@ mod tests {
|
|||||||
})?;
|
})?;
|
||||||
self.password_hasher.hash_password(password).await
|
self.password_hasher.hash_password(password).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirror of `ShareService::get_shared_link_meta_with_unlock` —
|
||||||
|
/// fetch by token (the mirror has no unlock-JWT machinery) plus the
|
||||||
|
/// shared file-info enrichment.
|
||||||
|
async fn get_shared_link_meta_with_unlock(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
_unlock_jwt: Option<&str>,
|
||||||
|
) -> Result<ShareDto, DomainError> {
|
||||||
|
let dto = self.get_shared_link_by_token(token).await?;
|
||||||
|
Ok(enrich_share_dto_with_file_info(dto, self.file_repository.as_ref()).await)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
|
impl<SR, FR, FoR, PH> ShareUseCase for ShareServiceForTest<SR, FR, FoR, PH>
|
||||||
@@ -1261,4 +1311,75 @@ mod tests {
|
|||||||
assert!(share_dto.has_password);
|
assert!(share_dto.has_password);
|
||||||
assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/"));
|
assert!(share_dto.url.starts_with("http://127.0.0.1:8086/s/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The share-landing meta endpoint enriches file shares with the shared
|
||||||
|
/// file's mime type + size so anonymous viewers can render an inline
|
||||||
|
/// preview (video player / image) instead of a bare download button.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_shared_link_meta_enriches_file_shares() {
|
||||||
|
let config = Arc::new(AppConfig::default());
|
||||||
|
let service = ShareServiceForTest::new(
|
||||||
|
config,
|
||||||
|
Arc::new(MockShareRepository::new()),
|
||||||
|
Arc::new(MockFileRepository),
|
||||||
|
Arc::new(MockFolderRepository),
|
||||||
|
Arc::new(MockPasswordHasher),
|
||||||
|
);
|
||||||
|
|
||||||
|
let share = service
|
||||||
|
.create_shared_link(
|
||||||
|
Uuid::new_v4(),
|
||||||
|
CreateShareDto {
|
||||||
|
item_id: "test_file_id".to_string(),
|
||||||
|
item_name: Some("movie.mp4".to_string()),
|
||||||
|
item_type: "file".to_string(),
|
||||||
|
password: None,
|
||||||
|
expires_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let meta = service
|
||||||
|
.get_shared_link_meta_with_unlock(&share.token, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(meta.mime_type.as_deref(), Some("text/plain"));
|
||||||
|
assert_eq!(meta.size, Some(123));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder shares must NOT gain a bogus mime type — the enrichment is a
|
||||||
|
/// file-share-only passthrough for them.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_shared_link_meta_leaves_folder_shares_unenriched() {
|
||||||
|
let config = Arc::new(AppConfig::default());
|
||||||
|
let service = ShareServiceForTest::new(
|
||||||
|
config,
|
||||||
|
Arc::new(MockShareRepository::new()),
|
||||||
|
Arc::new(MockFileRepository),
|
||||||
|
Arc::new(MockFolderRepository),
|
||||||
|
Arc::new(MockPasswordHasher),
|
||||||
|
);
|
||||||
|
|
||||||
|
let share = service
|
||||||
|
.create_shared_link(
|
||||||
|
Uuid::new_v4(),
|
||||||
|
CreateShareDto {
|
||||||
|
item_id: "test_folder_id".to_string(),
|
||||||
|
item_name: Some("pictures".to_string()),
|
||||||
|
item_type: "folder".to_string(),
|
||||||
|
password: None,
|
||||||
|
expires_at: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let meta = service
|
||||||
|
.get_shared_link_meta_with_unlock(&share.token, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(meta.mime_type, None);
|
||||||
|
assert_eq!(meta.size, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,23 @@ async fn fsync_paths_parallel(paths: Vec<PathBuf>, strict: bool) -> Result<(), D
|
|||||||
tasks.push(tokio::task::spawn_blocking(
|
tasks.push(tokio::task::spawn_blocking(
|
||||||
move || -> Result<(), (PathBuf, std::io::Error)> {
|
move || -> Result<(), (PathBuf, std::io::Error)> {
|
||||||
for path in &group {
|
for path in &group {
|
||||||
let result = std::fs::File::open(path).and_then(|f| f.sync_all());
|
// `strict` marks blob *file* fsyncs; best-effort marks
|
||||||
|
// prefix *directory* fsyncs. That distinction also picks
|
||||||
|
// the open mode: Windows `FlushFileBuffers` needs a
|
||||||
|
// GENERIC_WRITE handle and fails with ACCESS_DENIED on
|
||||||
|
// the read-only handle `File::open` returns (POSIX fsync
|
||||||
|
// accepts read-only fds, which is why this only surfaced
|
||||||
|
// on Windows). Directories keep the read-only POSIX
|
||||||
|
// dirent-sync idiom — they can't be fsync'd on Windows
|
||||||
|
// at all, and their failures stay best-effort warnings.
|
||||||
|
let result = if strict {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(path)
|
||||||
|
.and_then(|f| f.sync_all())
|
||||||
|
} else {
|
||||||
|
std::fs::File::open(path).and_then(|f| f.sync_all())
|
||||||
|
};
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
if strict {
|
if strict {
|
||||||
return Err((path.clone(), e));
|
return Err((path.clone(), e));
|
||||||
@@ -394,7 +410,11 @@ impl BlobStorageBackend for LocalBlobBackend {
|
|||||||
format!("Failed to copy file to blob store: {}", ce),
|
format!("Failed to copy file to blob store: {}", ce),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Ok(f) = fs::File::open(&blob_path).await {
|
// Open for write: Windows `FlushFileBuffers` requires a
|
||||||
|
// GENERIC_WRITE handle — the read-only handle from
|
||||||
|
// `File::open` fails with ACCESS_DENIED, silently
|
||||||
|
// skipping this fsync on every Windows deployment.
|
||||||
|
if let Ok(f) = fs::OpenOptions::new().write(true).open(&blob_path).await {
|
||||||
let _ = f.sync_all().await;
|
let _ = f.sync_all().await;
|
||||||
}
|
}
|
||||||
let _ = fs::remove_file(&source_path).await;
|
let _ = fs::remove_file(&source_path).await;
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ pub async fn access_shared_item(
|
|||||||
// every public share landing).
|
// every public share landing).
|
||||||
let (_, item) = tokio::join!(
|
let (_, item) = tokio::join!(
|
||||||
share_use_case.register_shared_link_access(&token),
|
share_use_case.register_shared_link_access(&token),
|
||||||
share_use_case.get_shared_link_with_unlock(&token, unlock_jwt.as_deref()),
|
share_use_case.get_shared_link_meta_with_unlock(&token, unlock_jwt.as_deref()),
|
||||||
);
|
);
|
||||||
|
|
||||||
match item {
|
match item {
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
|
|||||||
if reuse_port {
|
if reuse_port {
|
||||||
socket.set_reuse_port(true)?;
|
socket.set_reuse_port(true)?;
|
||||||
}
|
}
|
||||||
|
// SO_REUSEPORT is Unix-only; on Windows the flag is accepted but inert.
|
||||||
|
// Consume the parameter so `-D warnings` stays clean on Windows builds.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _ = reuse_port;
|
||||||
// Disable Nagle's algorithm — send small responses (JSON, PROPFIND)
|
// Disable Nagle's algorithm — send small responses (JSON, PROPFIND)
|
||||||
// immediately instead of waiting up to 40ms for coalescing.
|
// immediately instead of waiting up to 40ms for coalescing.
|
||||||
socket.set_tcp_nodelay(true)?;
|
socket.set_tcp_nodelay(true)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user