Compare commits

...

3 Commits

Author SHA1 Message Date
cjw d677f92b7f fix(main): consume reuse_port param on Windows builds
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Has been cancelled
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Has been cancelled
CI / Migration ordering (new migrations postdate target branch) (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Wasm — fmt + clippy (push) Has been cancelled
CI / Wasm — release tests (push) Has been cancelled
CI / Plugins — fixtures + runtime tests (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Server Unit and Functionnal Tests (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
SO_REUSEPORT is Unix-only; the parameter is only read inside the
#[cfg(not(windows))] block, so -D warnings fails the build with an
unused-variable error on Windows hosts while Linux CI stays green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:02:03 +08:00
cjw 5f9f91ee2f fix(blob): fsync blob files via a write handle so Windows works
The sync sweep (and the EXDEV copy fallback) opened blob files with
File::open — a read-only handle — before calling sync_all. POSIX fsync
accepts read-only fds, so Linux never noticed, but Windows
FlushFileBuffers requires a GENERIC_WRITE handle and fails with
ACCESS_DENIED (os error 5) on every call. On Windows deployments the
strict sweep therefore failed every deferred sync, and the post-copy
fsync silently never happened.

Files now open via OpenOptions::write(true); the best-effort directory
fsyncs keep the read-only POSIX dirent idiom unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 16:02:02 +08:00
cjw 6ee26e46e6 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>
2026-09-14 16:00:30 +08:00
7 changed files with 200 additions and 5 deletions
+10
View File
@@ -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 {
+30 -1
View File
@@ -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);
+11
View File
@@ -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,
}
}
}
+122 -1
View File
@@ -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;
+1 -1
View File
@@ -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 {
+4
View File
@@ -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)?;