feat(share): inline media preview on single-file share landing
The public share page only rendered media previews for FOLDER shares —
a single-file share got a bare icon + download button, even for images
and videos the browser can play natively.
Backend: resolve the shared file's mime_type + size at read time and
expose them on ShareDto (meta + password-verify endpoints, one shared
enrichment helper). Display-only enrichment: a failed file lookup
leaves the fields None instead of failing the response — the download
endpoint still surfaces the real error.
Frontend: the 'file' view now reuses the folder grid's lazyVideo
(poster-seek + retry) for video and imageRetry for images, with
Range-aware streaming already provided by /api/s/{token}/file/{id}.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user