DownloadLink
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path as FsPath;
|
use std::path::Path as FsPath;
|
||||||
|
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -13,6 +14,12 @@ use crate::schema::{document_assets, document_versions};
|
|||||||
use crate::state::{AppState, PgPooledConnection};
|
use crate::state::{AppState, PgPooledConnection};
|
||||||
use crate::utils::{http::inline_content_disposition, time::to_iso};
|
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)]
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
pub struct DocumentAssetResponse {
|
pub struct DocumentAssetResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -22,10 +29,7 @@ pub struct DocumentAssetResponse {
|
|||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub url: Option<String>,
|
pub download: Option<DownloadLink>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub expires_at: Option<i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -38,10 +42,7 @@ pub struct DocumentAssetDetailResponse {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub url: Option<String>,
|
pub download: Option<DownloadLink>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
#[schema(nullable)]
|
|
||||||
pub expires_at: Option<i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, ToSchema)]
|
#[derive(Serialize, Clone, ToSchema)]
|
||||||
@@ -61,23 +62,35 @@ pub struct DocumentVersionDetailResponse {
|
|||||||
pub version: DocumentVersionResponse,
|
pub version: DocumentVersionResponse,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub assets: Vec<DocumentAssetResponse>,
|
pub assets: Vec<DocumentAssetResponse>,
|
||||||
pub download_path: String,
|
pub download: DownloadLink,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_download_path(
|
pub fn build_download_link(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
document: &Document,
|
document: &Document,
|
||||||
version_id: Uuid,
|
version_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> AppResult<String> {
|
) -> AppResult<DownloadLink> {
|
||||||
state
|
state
|
||||||
.jwt
|
.jwt
|
||||||
.generate_download_token(document.id, version_id, user_id, document.tenant_id)
|
.generate_download_token(document.id, version_id, user_id, document.tenant_id)
|
||||||
.map(|token| format!("/api/download/{token}"))
|
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
tracing::error!(error = ?err, "failed to generate download token");
|
tracing::error!(error = ?err, "failed to generate download token");
|
||||||
AppError::internal("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 {
|
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,
|
asset_type: asset.asset_type,
|
||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata: asset.metadata,
|
metadata: asset.metadata,
|
||||||
url: None,
|
download: None,
|
||||||
expires_at: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_asset_detail_response(
|
pub fn to_asset_detail_response(
|
||||||
asset: DocumentAsset,
|
asset: DocumentAsset,
|
||||||
url: Option<String>,
|
download: Option<DownloadLink>,
|
||||||
expires_at: Option<i64>,
|
|
||||||
) -> DocumentAssetDetailResponse {
|
) -> DocumentAssetDetailResponse {
|
||||||
DocumentAssetDetailResponse {
|
DocumentAssetDetailResponse {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
@@ -113,8 +124,7 @@ pub fn to_asset_detail_response(
|
|||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata: asset.metadata,
|
metadata: asset.metadata,
|
||||||
created_at: to_iso(asset.created_at),
|
created_at: to_iso(asset.created_at),
|
||||||
url,
|
download,
|
||||||
expires_at,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use uuid::Uuid;
|
|||||||
use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn};
|
use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn};
|
||||||
use crate::documents::asset::{
|
use crate::documents::asset::{
|
||||||
asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse,
|
asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse,
|
||||||
DocumentVersionDetailResponse, DocumentVersionResponse,
|
DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink,
|
||||||
};
|
};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::error::ApiErrorResponse;
|
use crate::error::ApiErrorResponse;
|
||||||
@@ -492,6 +492,81 @@ pub async fn get_document_asset(
|
|||||||
ok_json(detail)
|
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<AppState>,
|
||||||
|
Path(document_id): Path<Uuid>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<JsonResponse<DownloadLink>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<JsonResponse<DownloadLink>> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(asset_id): Path<Uuid>,
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
) -> AppResult<JsonResponse<DownloadLink>> {
|
||||||
|
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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/documents/{id}/versions",
|
path = "/api/documents/{id}/versions",
|
||||||
@@ -1017,6 +1092,7 @@ pub async fn remove_tag(
|
|||||||
crate::routes::documents::check_document,
|
crate::routes::documents::check_document,
|
||||||
crate::routes::documents::upload_document,
|
crate::routes::documents::upload_document,
|
||||||
crate::routes::documents::get_document,
|
crate::routes::documents::get_document,
|
||||||
|
crate::routes::documents::refresh_document_download,
|
||||||
crate::routes::documents::update_document,
|
crate::routes::documents::update_document,
|
||||||
crate::routes::documents::trash_document,
|
crate::routes::documents::trash_document,
|
||||||
crate::routes::documents::delete_document,
|
crate::routes::documents::delete_document,
|
||||||
@@ -1036,6 +1112,8 @@ pub async fn remove_tag(
|
|||||||
crate::routes::documents::get_document_asset,
|
crate::routes::documents::get_document_asset,
|
||||||
crate::routes::documents::list_document_versions,
|
crate::routes::documents::list_document_versions,
|
||||||
crate::routes::documents::get_document_version,
|
crate::routes::documents::get_document_version,
|
||||||
|
crate::routes::documents::refresh_document_version_download,
|
||||||
|
crate::routes::documents::refresh_asset_download,
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
crate::services::documents::DocumentListQuery,
|
crate::services::documents::DocumentListQuery,
|
||||||
@@ -1066,6 +1144,7 @@ pub async fn remove_tag(
|
|||||||
crate::documents::asset::DocumentVersionDetailResponse,
|
crate::documents::asset::DocumentVersionDetailResponse,
|
||||||
crate::documents::asset::DocumentAssetResponse,
|
crate::documents::asset::DocumentAssetResponse,
|
||||||
crate::documents::asset::DocumentAssetDetailResponse,
|
crate::documents::asset::DocumentAssetDetailResponse,
|
||||||
|
crate::documents::asset::DownloadLink,
|
||||||
crate::documents::correspondents::DocumentCorrespondentResponse,
|
crate::documents::correspondents::DocumentCorrespondentResponse,
|
||||||
crate::error::ApiErrorResponse,
|
crate::error::ApiErrorResponse,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
ApiCapability::DocumentsRead,
|
ApiCapability::DocumentsRead,
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/download",
|
||||||
|
post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([
|
||||||
|
ApiCapability::DocumentsRead,
|
||||||
|
])),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{id}/trash",
|
"/{id}/trash",
|
||||||
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
|
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
|
||||||
@@ -178,6 +184,12 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
ApiCapability::DocumentsRead,
|
ApiCapability::DocumentsRead,
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/{id}/versions/{version_id}/download",
|
||||||
|
post(documents::refresh_document_version_download).layer(
|
||||||
|
RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]),
|
||||||
|
),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{id}/restore",
|
"/{id}/restore",
|
||||||
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
|
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
|
||||||
@@ -366,12 +378,19 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
let assets_routes = Router::new().route(
|
let assets_routes = Router::new()
|
||||||
"/{asset_id}",
|
.route(
|
||||||
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
|
"/{asset_id}",
|
||||||
ApiCapability::DocumentsRead,
|
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 manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]);
|
||||||
let tenants_routes = Router::new()
|
let tenants_routes = Router::new()
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::documents::{
|
use crate::documents::{
|
||||||
asset::{
|
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,
|
to_asset_detail_response, to_version_response, DocumentAssetDetailResponse,
|
||||||
DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse,
|
DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse,
|
||||||
|
DownloadLink,
|
||||||
},
|
},
|
||||||
correspondents::{
|
correspondents::{
|
||||||
insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse,
|
insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse,
|
||||||
@@ -359,7 +360,8 @@ impl<'a> DocumentsService<'a> {
|
|||||||
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
||||||
|
|
||||||
let assets = self.load_asset_responses(conn, tenant_id, current_version.id, user_id)?;
|
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 =
|
let response =
|
||||||
self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?;
|
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
|
let current_version = doc_to_version
|
||||||
.get(&doc.id)
|
.get(&doc.id)
|
||||||
.and_then(|version_id| version_map.remove(version_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();
|
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)
|
self.to_document_response(user_id, doc, tags, correspondents, current_version)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -818,13 +822,15 @@ impl<'a> DocumentsService<'a> {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
||||||
|
|
||||||
|
let download = build_download_link(self.state, &document, version.id, user_id)?;
|
||||||
|
|
||||||
DocumentDetailResponse {
|
DocumentDetailResponse {
|
||||||
document: self.to_document_response(
|
document: self.to_document_response(
|
||||||
user_id,
|
user_id,
|
||||||
document,
|
document,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
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);
|
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<DownloadLink> {
|
||||||
|
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<DownloadLink> {
|
||||||
|
let document = load_active_document(conn, tenant_id, document_id)?;
|
||||||
|
|
||||||
|
let version: Option<DocumentVersion> = 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<DownloadLink> {
|
||||||
|
let asset: Option<DocumentAsset> = 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(
|
pub fn list_document_versions(
|
||||||
@@ -1001,13 +1067,13 @@ impl<'a> DocumentsService<'a> {
|
|||||||
.first(conn)?;
|
.first(conn)?;
|
||||||
|
|
||||||
let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?;
|
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);
|
let version_core = to_version_response(version);
|
||||||
|
|
||||||
Ok(DocumentVersionDetailResponse {
|
Ok(DocumentVersionDetailResponse {
|
||||||
version: version_core,
|
version: version_core,
|
||||||
assets,
|
assets,
|
||||||
download_path,
|
download,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,6 +1223,7 @@ impl<'a> DocumentsService<'a> {
|
|||||||
let version_id = current_version.id;
|
let version_id = current_version.id;
|
||||||
let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?;
|
let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?;
|
||||||
let version_response = to_version_response(current_version);
|
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
|
let (tags, correspondents) = tags_and_correspondents
|
||||||
.get(&document_id)
|
.get(&document_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -1167,7 +1234,7 @@ impl<'a> DocumentsService<'a> {
|
|||||||
document,
|
document,
|
||||||
tags,
|
tags,
|
||||||
correspondents,
|
correspondents,
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets, download)),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(DocumentDetailResponse {
|
Ok(DocumentDetailResponse {
|
||||||
@@ -1327,21 +1394,22 @@ impl<'a> DocumentsService<'a> {
|
|||||||
|
|
||||||
fn to_document_response(
|
fn to_document_response(
|
||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
_user_id: Uuid,
|
||||||
doc: Document,
|
doc: Document,
|
||||||
tags: Vec<Tag>,
|
tags: Vec<Tag>,
|
||||||
correspondents: Vec<DocumentCorrespondentResponse>,
|
correspondents: Vec<DocumentCorrespondentResponse>,
|
||||||
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
current_version: Option<(
|
||||||
|
DocumentVersionResponse,
|
||||||
|
Vec<DocumentAssetResponse>,
|
||||||
|
DownloadLink,
|
||||||
|
)>,
|
||||||
) -> AppResult<DocumentResponse> {
|
) -> AppResult<DocumentResponse> {
|
||||||
let current_version = match current_version {
|
let current_version = match current_version {
|
||||||
Some((version, assets)) => {
|
Some((version, assets, download)) => Some(DocumentVersionDetailResponse {
|
||||||
let download_path = build_download_path(self.state, &doc, version.id, user_id)?;
|
version,
|
||||||
Some(DocumentVersionDetailResponse {
|
assets,
|
||||||
version,
|
download,
|
||||||
assets,
|
}),
|
||||||
download_path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1469,6 +1537,7 @@ impl<'a> DocumentsService<'a> {
|
|||||||
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
.unwrap_or_else(|| (Vec::new(), Vec::new()));
|
||||||
|
|
||||||
let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?;
|
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());
|
let version_response = to_version_response(version.clone());
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -1483,19 +1552,19 @@ impl<'a> DocumentsService<'a> {
|
|||||||
document,
|
document,
|
||||||
tags,
|
tags,
|
||||||
correspondents_list,
|
correspondents_list,
|
||||||
Some((version_response, assets)),
|
Some((version_response, assets, download)),
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(detail))
|
Ok(Some(detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_download_url(
|
fn asset_download_link(
|
||||||
&self,
|
&self,
|
||||||
asset_id: Uuid,
|
asset_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> AppResult<(String, i64)> {
|
) -> AppResult<DownloadLink> {
|
||||||
let token = self
|
let token = self
|
||||||
.state
|
.state
|
||||||
.jwt
|
.jwt
|
||||||
@@ -1512,7 +1581,10 @@ impl<'a> DocumentsService<'a> {
|
|||||||
.ok_or_else(|| AppError::internal("failed to compute download expiry"))?
|
.ok_or_else(|| AppError::internal("failed to compute download expiry"))?
|
||||||
.timestamp_millis();
|
.timestamp_millis();
|
||||||
|
|
||||||
Ok((format!("/api/download/{token}"), expires_at))
|
Ok(DownloadLink {
|
||||||
|
url: format!("/api/download/{token}"),
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_response(
|
fn asset_response(
|
||||||
@@ -1521,15 +1593,14 @@ impl<'a> DocumentsService<'a> {
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> AppResult<DocumentAssetResponse> {
|
) -> AppResult<DocumentAssetResponse> {
|
||||||
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 {
|
Ok(DocumentAssetResponse {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
asset_type: asset.asset_type,
|
asset_type: asset.asset_type,
|
||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata: asset.metadata,
|
metadata: asset.metadata,
|
||||||
url: Some(url),
|
download: Some(download),
|
||||||
expires_at: Some(expires_at),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,17 @@ struct DocumentVersionPayload {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
version_number: i32,
|
version_number: i32,
|
||||||
size_bytes: i64,
|
size_bytes: i64,
|
||||||
download_path: String,
|
download: DownloadLinkPayload,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
assets: Vec<DocumentAssetInfo>,
|
assets: Vec<DocumentAssetInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DownloadLinkPayload {
|
||||||
|
url: String,
|
||||||
|
expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentVersionListItem {
|
struct DocumentVersionListItem {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -67,8 +73,7 @@ struct DocumentAssetInfo {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AssetProxyDetail {
|
struct AssetProxyDetail {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
url: Option<String>,
|
download: Option<DownloadLinkPayload>,
|
||||||
expires_at: Option<i64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -193,7 +198,8 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
.current_version
|
.current_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("current version detail");
|
.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.version_number, 1);
|
||||||
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||||
assert!(current_version.assets.is_empty());
|
assert!(current_version.assets.is_empty());
|
||||||
@@ -224,10 +230,11 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
.current_version
|
.current_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("list current version")
|
.expect("list current version")
|
||||||
.download_path
|
.download
|
||||||
|
.url
|
||||||
.starts_with("/api/download/"));
|
.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);
|
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||||
let location = redirect
|
let location = redirect
|
||||||
.headers()
|
.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 body = body_to_vec(response.into_body()).await?;
|
||||||
let asset_detail: AssetProxyDetail = serde_json::from_slice(&body)?;
|
let asset_detail: AssetProxyDetail = serde_json::from_slice(&body)?;
|
||||||
assert_eq!(asset_detail.id, asset_id);
|
assert_eq!(asset_detail.id, asset_id);
|
||||||
let url = asset_detail
|
let download = asset_detail
|
||||||
.url
|
.download
|
||||||
.as_deref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow!("missing url"))?;
|
.ok_or_else(|| anyhow!("missing download link"))?;
|
||||||
assert!(url.starts_with("/api/download/"));
|
assert!(download.url.starts_with("/api/download/"));
|
||||||
assert!(asset_detail.expires_at.is_some());
|
assert!(download.expires_at > 0);
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
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 detail_body = body_to_vec(detail_resp.into_body()).await?;
|
||||||
let version_detail: DocumentVersionPayload = serde_json::from_slice(&detail_body)?;
|
let version_detail: DocumentVersionPayload = serde_json::from_slice(&detail_body)?;
|
||||||
assert_eq!(version_detail.id, version_id);
|
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());
|
assert!(version_detail.assets.is_empty());
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ struct DocumentInfo {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentVersion {
|
struct DocumentVersion {
|
||||||
download_path: String,
|
download: DownloadLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DownloadLink {
|
||||||
|
url: String,
|
||||||
|
expires_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -46,7 +52,8 @@ async fn document_download_redirects_when_proxy_disabled() -> Result<()> {
|
|||||||
.current_version
|
.current_version
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("missing version")
|
.expect("missing version")
|
||||||
.download_path
|
.download
|
||||||
|
.url
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let redirect = app.get(&download_path, None).await?;
|
let redirect = app.get(&download_path, None).await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user