Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d677f92b7f | |||
| 5f9f91ee2f | |||
| 6ee26e46e6 |
@@ -9,6 +9,16 @@ import type { ItemType } from '$lib/api/types';
|
||||
export interface ShareMeta {
|
||||
item_type: ItemType;
|
||||
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 {
|
||||
|
||||
@@ -287,7 +287,29 @@
|
||||
</form>
|
||||
{:else if view === 'file'}
|
||||
<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>
|
||||
<a
|
||||
class="share__btn"
|
||||
@@ -486,6 +508,13 @@
|
||||
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) {
|
||||
font-size: 3rem;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -16,6 +16,15 @@ pub struct ShareDto {
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
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)]
|
||||
@@ -49,6 +58,8 @@ impl ShareDto {
|
||||
created_at: share.created_at(),
|
||||
created_by: share.created_by().to_string(),
|
||||
access_count: share.access_count(),
|
||||
mime_type: None,
|
||||
size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,18 @@ impl ShareService {
|
||||
};
|
||||
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 {
|
||||
@@ -538,7 +550,8 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
|
||||
// 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> {
|
||||
@@ -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")]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
@@ -642,6 +680,18 @@ mod tests {
|
||||
})?;
|
||||
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>
|
||||
@@ -1261,4 +1311,75 @@ mod tests {
|
||||
assert!(share_dto.has_password);
|
||||
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(
|
||||
move || -> Result<(), (PathBuf, std::io::Error)> {
|
||||
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 strict {
|
||||
return Err((path.clone(), e));
|
||||
@@ -394,7 +410,11 @@ impl BlobStorageBackend for LocalBlobBackend {
|
||||
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 _ = fs::remove_file(&source_path).await;
|
||||
|
||||
@@ -242,7 +242,7 @@ pub async fn access_shared_item(
|
||||
// every public share landing).
|
||||
let (_, item) = tokio::join!(
|
||||
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 {
|
||||
|
||||
@@ -100,6 +100,10 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
|
||||
if reuse_port {
|
||||
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)
|
||||
// immediately instead of waiting up to 40ms for coalescing.
|
||||
socket.set_tcp_nodelay(true)?;
|
||||
|
||||
Reference in New Issue
Block a user