fix(share): stream single-file shares through /api/s/{token}/file/{id}
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
Docker Publish (release, main, dry-run) / Pre-publish Tests (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 / Server Unit and Functionnal Tests (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
Docker Publish (release, main, dry-run) / Pre-publish Tests (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 / Server Unit and Functionnal Tests (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled
The public landing page's inline media preview (added in 6ee26e46)
requests /api/s/{token}/file/{item_id}, but assert_file_in_share went
through resolve_folder_share, which hard-rejects non-folder shares —
so for a single-file share the video src got a 400 and the player
rendered empty: the preview box appeared but nothing would play.
The AuthZ gate now branches on item_type instead: a file share only
accepts file_id == share.item_id, a folder share still requires the
file to live in the shared subtree, and anything else is NotFound
(same shape as "file doesn't exist", preserving anti-enumeration).
Password/expiry checks still happen inside
get_shared_link_with_unlock, unchanged.
Also extend public_shares.hurl section 8b: the file-share token must
stream its own item (200 + inline disposition) and reject an outsider
file id with 404.
NOTE: fmt/clippy/api-test could not run on the authoring machine (no
Rust toolchain or Docker) — run `just check` + `just api-test` before
pushing.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -116,19 +116,35 @@ impl ShareBrowseService {
|
|||||||
self.list_inner(folder_id, resolved.owner_id).await
|
self.list_inner(folder_id, resolved.owner_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AuthZ gate for `/api/s/{token}/file/{file_id}`: the requested file
|
||||||
|
/// must either BE the shared item (single-file share — the public landing
|
||||||
|
/// page's inline media preview streams through here) or live inside the
|
||||||
|
/// shared folder's subtree (folder share). Anything else is NotFound —
|
||||||
|
/// the same shape as "file doesn't exist", so the endpoint can't be used
|
||||||
|
/// to enumerate file ids.
|
||||||
pub async fn assert_file_in_share(
|
pub async fn assert_file_in_share(
|
||||||
&self,
|
&self,
|
||||||
token: &str,
|
token: &str,
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
unlock_jwt: Option<&str>,
|
unlock_jwt: Option<&str>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let resolved = self.resolve_folder_share(token, unlock_jwt).await?;
|
let share = self
|
||||||
|
.share_service
|
||||||
|
.get_shared_link_with_unlock(token, unlock_jwt)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !self
|
let in_scope = match share.item_type.as_str() {
|
||||||
.folder_repo
|
// Single-file share: only the shared item itself may be streamed.
|
||||||
.is_file_in_subtree(file_id, &resolved.root_folder_id)
|
"file" => file_id == share.item_id,
|
||||||
.await?
|
// Folder share: the file must live in the shared subtree.
|
||||||
{
|
"folder" => {
|
||||||
|
self.folder_repo
|
||||||
|
.is_file_in_subtree(file_id, &share.item_id)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !in_scope {
|
||||||
return Err(DomainError::not_found("File", file_id));
|
return Err(DomainError::not_found("File", file_id));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ pub async fn list_share_contents_subfolder(
|
|||||||
path = "/api/s/{token}/file/{file_id}",
|
path = "/api/s/{token}/file/{file_id}",
|
||||||
params(
|
params(
|
||||||
("token" = String, Path, description = "Share token"),
|
("token" = String, Path, description = "Share token"),
|
||||||
("file_id" = String, Path, description = "File ID (must be inside the share)")
|
("file_id" = String, Path, description = "File ID (the shared item itself, or a file inside the shared folder's subtree)")
|
||||||
),
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "File content (or 206 for Range request)"),
|
(status = 200, description = "File content (or 206 for Range request)"),
|
||||||
|
|||||||
@@ -197,6 +197,24 @@ HTTP 200
|
|||||||
jsonpath "$.item_type" == "file"
|
jsonpath "$.item_type" == "file"
|
||||||
|
|
||||||
|
|
||||||
|
# The public landing page's inline media preview streams the shared file
|
||||||
|
# through /api/s/{token}/file/{file_id} — the requested file IS the shared
|
||||||
|
# item here, so the AuthZ gate must accept it (Range-aware 200, inline
|
||||||
|
# disposition so <video>/<img> can render it).
|
||||||
|
GET {{base_url}}/api/s/{{file_share_token}}/file/{{shared_file_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
header "Content-Disposition" contains "hello.txt"
|
||||||
|
|
||||||
|
|
||||||
|
# A file id that is NOT the shared item must 404 on a file-share token —
|
||||||
|
# same anti-enumeration shape as the folder-share probe above.
|
||||||
|
GET {{base_url}}/api/s/{{file_share_token}}/file/{{outsider_file_id}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# 9 — Mint a password-protected share on the same folder.
|
# 9 — Mint a password-protected share on the same folder.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user