diff --git a/frontend/src/lib/api/endpoints/share.ts b/frontend/src/lib/api/endpoints/share.ts
index 1b69f68a..154d3f4f 100644
--- a/frontend/src/lib/api/endpoints/share.ts
+++ b/frontend/src/lib/api/endpoints/share.ts
@@ -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 {
diff --git a/frontend/src/routes/s/[token]/+page.svelte b/frontend/src/routes/s/[token]/+page.svelte
index 570a536c..9e61d933 100644
--- a/frontend/src/routes/s/[token]/+page.svelte
+++ b/frontend/src/routes/s/[token]/+page.svelte
@@ -287,7 +287,29 @@
{:else if view === 'file'}
-
+ {#if meta && mediaKind(meta.mime_type) === 'video'}
+
+
+ {:else if meta && mediaKind(meta.mime_type) === 'image'}
+

+ {:else}
+
+ {/if}
{meta?.item_name}
,
+ /// File shares only: the shared file's size in bytes (see `mime_type`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub size: Option,
}
#[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,
}
}
}
diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs
index 9e9ac159..47b7ff7f 100644
--- a/src/application/services/share_service.rs
+++ b/src/application/services/share_service.rs
@@ -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 {
+ 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(
+ 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 {
+ 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 ShareUseCase for ShareServiceForTest
@@ -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);
+ }
}
diff --git a/src/interfaces/api/handlers/share_handler.rs b/src/interfaces/api/handlers/share_handler.rs
index 4343f0ad..bc20fe12 100644
--- a/src/interfaces/api/handlers/share_handler.rs
+++ b/src/interfaces/api/handlers/share_handler.rs
@@ -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 {