From 5871c7ae6e8c3fbdce2b1aa7d64a099d84f2bc86 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 21 Nov 2025 13:53:13 +0100 Subject: [PATCH] DownloadLink --- backend/src/documents/asset.rs | 46 ++++++----- backend/src/routes/documents.rs | 81 ++++++++++++++++++- backend/src/routes/mod.rs | 31 ++++++-- backend/src/services/documents.rs | 127 +++++++++++++++++++++++------- backend/tests/documents_flow.rs | 34 +++++--- backend/tests/download_flow.rs | 11 ++- 6 files changed, 262 insertions(+), 68 deletions(-) diff --git a/backend/src/documents/asset.rs b/backend/src/documents/asset.rs index 1c4c6fe..4ee4788 100644 --- a/backend/src/documents/asset.rs +++ b/backend/src/documents/asset.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::Path as FsPath; +use chrono::{Duration as ChronoDuration, Utc}; use diesel::prelude::*; use serde::Serialize; use serde_json::Value; @@ -13,6 +14,12 @@ use crate::schema::{document_assets, document_versions}; use crate::state::{AppState, PgPooledConnection}; use crate::utils::{http::inline_content_disposition, time::to_iso}; +#[derive(Serialize, Clone, ToSchema)] +pub struct DownloadLink { + pub url: String, + pub expires_at: i64, +} + #[derive(Serialize, Clone, ToSchema)] pub struct DocumentAssetResponse { pub id: Uuid, @@ -22,10 +29,7 @@ pub struct DocumentAssetResponse { pub metadata: Value, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable)] - pub url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub expires_at: Option, + pub download: Option, } #[derive(Serialize, ToSchema)] @@ -38,10 +42,7 @@ pub struct DocumentAssetDetailResponse { pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable)] - pub url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable)] - pub expires_at: Option, + pub download: Option, } #[derive(Serialize, Clone, ToSchema)] @@ -61,23 +62,35 @@ pub struct DocumentVersionDetailResponse { pub version: DocumentVersionResponse, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub assets: Vec, - pub download_path: String, + pub download: DownloadLink, } -pub fn build_download_path( +pub fn build_download_link( state: &AppState, document: &Document, version_id: Uuid, user_id: Uuid, -) -> AppResult { +) -> AppResult { state .jwt .generate_download_token(document.id, version_id, user_id, document.tenant_id) - .map(|token| format!("/api/download/{token}")) .map_err(|err| { tracing::error!(error = ?err, "failed to generate download token"); AppError::internal("failed to generate download token") }) + .and_then(|token| { + let expires_at = Utc::now() + .checked_add_signed(ChronoDuration::minutes( + state.config.download_token_expiry_minutes, + )) + .ok_or_else(|| AppError::internal("failed to compute download expiry"))? + .timestamp_millis(); + + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) + }) } pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { @@ -97,15 +110,13 @@ pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse { asset_type: asset.asset_type, mime_type: asset.mime_type, metadata: asset.metadata, - url: None, - expires_at: None, + download: None, } } pub fn to_asset_detail_response( asset: DocumentAsset, - url: Option, - expires_at: Option, + download: Option, ) -> DocumentAssetDetailResponse { DocumentAssetDetailResponse { id: asset.id, @@ -113,8 +124,7 @@ pub fn to_asset_detail_response( mime_type: asset.mime_type, metadata: asset.metadata, created_at: to_iso(asset.created_at), - url, - expires_at, + download, } } diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 6dd1ee7..1564c53 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -17,7 +17,7 @@ use uuid::Uuid; use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn}; use crate::documents::asset::{ asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse, - DocumentVersionDetailResponse, DocumentVersionResponse, + DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink, }; #[allow(unused_imports)] use crate::error::ApiErrorResponse; @@ -492,6 +492,81 @@ pub async fn get_document_asset( ok_json(detail) } +#[utoipa::path( + post, + path = "/api/documents/{id}/download", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Download link for current version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_download( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_download_link(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/versions/{version_id}/download", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID") + ), + responses((status = 200, description = "Download link for version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_version_download( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_version_download_link(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/assets/{asset_id}/download", + params(("asset_id" = Uuid, Path, description = "Asset ID")), + responses((status = 200, description = "Asset download link", body = DownloadLink)), + tag = "Assets" +)] +pub async fn refresh_asset_download( + State(state): State, + Path(asset_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_asset_download_link(&mut conn, tenant_id, user_id, asset_id) + .await?; + ok_json(link) +} + #[utoipa::path( get, path = "/api/documents/{id}/versions", @@ -1017,6 +1092,7 @@ pub async fn remove_tag( crate::routes::documents::check_document, crate::routes::documents::upload_document, crate::routes::documents::get_document, + crate::routes::documents::refresh_document_download, crate::routes::documents::update_document, crate::routes::documents::trash_document, crate::routes::documents::delete_document, @@ -1036,6 +1112,8 @@ pub async fn remove_tag( crate::routes::documents::get_document_asset, crate::routes::documents::list_document_versions, crate::routes::documents::get_document_version, + crate::routes::documents::refresh_document_version_download, + crate::routes::documents::refresh_asset_download, ), components(schemas( crate::services::documents::DocumentListQuery, @@ -1066,6 +1144,7 @@ pub async fn remove_tag( crate::documents::asset::DocumentVersionDetailResponse, crate::documents::asset::DocumentAssetResponse, crate::documents::asset::DocumentAssetDetailResponse, + crate::documents::asset::DownloadLink, crate::documents::correspondents::DocumentCorrespondentResponse, crate::error::ApiErrorResponse, )) diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 68c0b62..147c024 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -130,6 +130,12 @@ pub fn create_router(state: AppState) -> Router<()> { ApiCapability::DocumentsRead, ])), ) + .route( + "/{id}/download", + post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) .route( "/{id}/trash", post(documents::trash_document).layer(RequireCapabilitiesLayer::all([ @@ -178,6 +184,12 @@ pub fn create_router(state: AppState) -> Router<()> { ApiCapability::DocumentsRead, ])), ) + .route( + "/{id}/versions/{version_id}/download", + post(documents::refresh_document_version_download).layer( + RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]), + ), + ) .route( "/{id}/restore", post(documents::restore_document).layer(RequireCapabilitiesLayer::all([ @@ -366,12 +378,19 @@ pub fn create_router(state: AppState) -> Router<()> { ); let protected_state = state.clone(); - let assets_routes = Router::new().route( - "/{asset_id}", - get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ - ApiCapability::DocumentsRead, - ])), - ); + let assets_routes = Router::new() + .route( + "/{asset_id}", + get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{asset_id}/download", + post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ); let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]); let tenants_routes = Router::new() diff --git a/backend/src/services/documents.rs b/backend/src/services/documents.rs index 3118d64..c0f4641 100644 --- a/backend/src/services/documents.rs +++ b/backend/src/services/documents.rs @@ -17,9 +17,10 @@ use uuid::Uuid; use crate::documents::{ asset::{ - build_download_path, derive_document_title, filename_with_retained_extension, + build_download_link, derive_document_title, filename_with_retained_extension, to_asset_detail_response, to_version_response, DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, + DownloadLink, }, correspondents::{ insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse, @@ -359,7 +360,8 @@ impl<'a> DocumentsService<'a> { .unwrap_or_else(|| (Vec::new(), Vec::new())); let assets = self.load_asset_responses(conn, tenant_id, current_version.id, user_id)?; - let current_version_data = Some((to_version_response(current_version), assets)); + let download = build_download_link(self.state, &doc, current_version.id, user_id)?; + let current_version_data = Some((to_version_response(current_version), assets, download)); let response = self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?; @@ -662,10 +664,12 @@ impl<'a> DocumentsService<'a> { let current_version = doc_to_version .get(&doc.id) .and_then(|version_id| version_map.remove(version_id)) - .map(|version| { + .map(|version| -> AppResult<_> { let assets = assets_by_version.remove(&version.id).unwrap_or_default(); - (to_version_response(version), assets) - }); + let download = build_download_link(self.state, &doc, version.id, user_id)?; + Ok((to_version_response(version), assets, download)) + }) + .transpose()?; self.to_document_response(user_id, doc, tags, correspondents, current_version) }) .collect() @@ -818,13 +822,15 @@ impl<'a> DocumentsService<'a> { .cloned() .unwrap_or_else(|| (Vec::new(), Vec::new())); + let download = build_download_link(self.state, &document, version.id, user_id)?; + DocumentDetailResponse { document: self.to_document_response( user_id, document, tags, correspondents, - Some((to_version_response(version.clone()), Vec::new())), + Some((to_version_response(version.clone()), Vec::new(), download)), )?, } }; @@ -962,9 +968,69 @@ impl<'a> DocumentsService<'a> { drop(conn); - let (url, expires_at) = self.asset_download_url(asset.id, tenant_id, user_id)?; + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; - Ok(to_asset_detail_response(asset, Some(url), Some(expires_at))) + Ok(to_asset_detail_response(asset, Some(download))) + } + + pub async fn get_document_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + let version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_document_version_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: Option = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(version) = version else { + return Err(AppError::not_found()); + }; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_asset_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + asset_id: Uuid, + ) -> AppResult { + let asset: Option = document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(asset) = asset else { + return Err(AppError::not_found()); + }; + + self.asset_download_link(asset.id, tenant_id, user_id) } pub fn list_document_versions( @@ -1001,13 +1067,13 @@ impl<'a> DocumentsService<'a> { .first(conn)?; let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; - let download_path = build_download_path(self.state, &document, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; let version_core = to_version_response(version); Ok(DocumentVersionDetailResponse { version: version_core, assets, - download_path, + download, }) } @@ -1157,6 +1223,7 @@ impl<'a> DocumentsService<'a> { let version_id = current_version.id; let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?; let version_response = to_version_response(current_version); + let download = build_download_link(self.state, &document, version_id, user_id)?; let (tags, correspondents) = tags_and_correspondents .get(&document_id) .cloned() @@ -1167,7 +1234,7 @@ impl<'a> DocumentsService<'a> { document, tags, correspondents, - Some((version_response, assets)), + Some((version_response, assets, download)), )?; Ok(DocumentDetailResponse { @@ -1327,21 +1394,22 @@ impl<'a> DocumentsService<'a> { fn to_document_response( &self, - user_id: Uuid, + _user_id: Uuid, doc: Document, tags: Vec, correspondents: Vec, - current_version: Option<(DocumentVersionResponse, Vec)>, + current_version: Option<( + DocumentVersionResponse, + Vec, + DownloadLink, + )>, ) -> AppResult { let current_version = match current_version { - Some((version, assets)) => { - let download_path = build_download_path(self.state, &doc, version.id, user_id)?; - Some(DocumentVersionDetailResponse { - version, - assets, - download_path, - }) - } + Some((version, assets, download)) => Some(DocumentVersionDetailResponse { + version, + assets, + download, + }), None => None, }; @@ -1469,6 +1537,7 @@ impl<'a> DocumentsService<'a> { .unwrap_or_else(|| (Vec::new(), Vec::new())); let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; let version_response = to_version_response(version.clone()); info!( @@ -1483,19 +1552,19 @@ impl<'a> DocumentsService<'a> { document, tags, correspondents_list, - Some((version_response, assets)), + Some((version_response, assets, download)), )?, }; Ok(Some(detail)) } - fn asset_download_url( + fn asset_download_link( &self, asset_id: Uuid, tenant_id: Uuid, user_id: Uuid, - ) -> AppResult<(String, i64)> { + ) -> AppResult { let token = self .state .jwt @@ -1512,7 +1581,10 @@ impl<'a> DocumentsService<'a> { .ok_or_else(|| AppError::internal("failed to compute download expiry"))? .timestamp_millis(); - Ok((format!("/api/download/{token}"), expires_at)) + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) } fn asset_response( @@ -1521,15 +1593,14 @@ impl<'a> DocumentsService<'a> { tenant_id: Uuid, user_id: Uuid, ) -> AppResult { - let (url, expires_at) = self.asset_download_url(asset.id, tenant_id, user_id)?; + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; Ok(DocumentAssetResponse { id: asset.id, asset_type: asset.asset_type, mime_type: asset.mime_type, metadata: asset.metadata, - url: Some(url), - expires_at: Some(expires_at), + download: Some(download), }) } diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index 5735362..8e2ad0f 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -46,11 +46,17 @@ struct DocumentVersionPayload { id: Uuid, version_number: i32, size_bytes: i64, - download_path: String, + download: DownloadLinkPayload, #[serde(default)] assets: Vec, } +#[derive(Deserialize)] +struct DownloadLinkPayload { + url: String, + expires_at: i64, +} + #[derive(Deserialize)] struct DocumentVersionListItem { id: Uuid, @@ -67,8 +73,7 @@ struct DocumentAssetInfo { #[derive(Deserialize)] struct AssetProxyDetail { id: Uuid, - url: Option, - expires_at: Option, + download: Option, } #[derive(Deserialize)] @@ -193,7 +198,8 @@ async fn upload_and_list_document() -> Result<()> { .current_version .as_ref() .expect("current version detail"); - assert!(current_version.download_path.starts_with("/api/download/")); + assert!(current_version.download.url.starts_with("/api/download/")); + assert!(current_version.download.expires_at > 0); assert_eq!(current_version.version_number, 1); assert_eq!(current_version.size_bytes, file_bytes.len() as i64); assert!(current_version.assets.is_empty()); @@ -224,10 +230,11 @@ async fn upload_and_list_document() -> Result<()> { .current_version .as_ref() .expect("list current version") - .download_path + .download + .url .starts_with("/api/download/")); - let redirect = app.get(¤t_version.download_path, None).await?; + let redirect = app.get(¤t_version.download.url, None).await?; assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); let location = redirect .headers() @@ -343,12 +350,12 @@ async fn asset_detail_uses_proxy_urls_when_configured() -> Result<()> { let body = body_to_vec(response.into_body()).await?; let asset_detail: AssetProxyDetail = serde_json::from_slice(&body)?; assert_eq!(asset_detail.id, asset_id); - let url = asset_detail - .url - .as_deref() - .ok_or_else(|| anyhow!("missing url"))?; - assert!(url.starts_with("/api/download/")); - assert!(asset_detail.expires_at.is_some()); + let download = asset_detail + .download + .as_ref() + .ok_or_else(|| anyhow!("missing download link"))?; + assert!(download.url.starts_with("/api/download/")); + assert!(download.expires_at > 0); app.cleanup().await?; Ok(()) @@ -2055,7 +2062,8 @@ async fn list_document_versions_and_fetch_detail() -> Result<()> { let detail_body = body_to_vec(detail_resp.into_body()).await?; let version_detail: DocumentVersionPayload = serde_json::from_slice(&detail_body)?; assert_eq!(version_detail.id, version_id); - assert!(version_detail.download_path.starts_with("/api/download/")); + assert!(version_detail.download.url.starts_with("/api/download/")); + assert!(version_detail.download.expires_at > 0); assert!(version_detail.assets.is_empty()); app.cleanup().await?; diff --git a/backend/tests/download_flow.rs b/backend/tests/download_flow.rs index 56df5fc..d7a1204 100644 --- a/backend/tests/download_flow.rs +++ b/backend/tests/download_flow.rs @@ -15,7 +15,13 @@ struct DocumentInfo { #[derive(Deserialize)] struct DocumentVersion { - download_path: String, + download: DownloadLink, +} + +#[derive(Deserialize)] +struct DownloadLink { + url: String, + expires_at: i64, } #[tokio::test] @@ -46,7 +52,8 @@ async fn document_download_redirects_when_proxy_disabled() -> Result<()> { .current_version .as_ref() .expect("missing version") - .download_path + .download + .url .clone(); let redirect = app.get(&download_path, None).await?;