/api/download

This commit is contained in:
2025-11-11 02:19:39 +01:00
parent 425e03650f
commit ac9e688e7a
10 changed files with 116 additions and 29 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ The backend reads its settings from environment variables. In particular:
- `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size. - `DATABASE_MAX_POOL_SIZE` optional override for the r2d2 connection pool size.
Defaults to `2`; increase it in staging/production to match expected concurrency. Defaults to `2`; increase it in staging/production to match expected concurrency.
- `PROXY_DOWNLOADS` set to `true` when the object store is only reachable from - `PROXY_DOWNLOADS` set to `true` when the object store is only reachable from
the backend network. When enabled, `/download/{token}` and asset-object fetches the backend network. When enabled, `/api/download/{token}` and asset-object fetches
stream bytes through the API instead of redirecting clients to S3/Hetzner. stream bytes through the API instead of redirecting clients to S3/Hetzner.
On startup each binary logs the effective configuration with secrets redacted On startup each binary logs the effective configuration with secrets redacted
+1 -1
View File
@@ -83,7 +83,7 @@ pub fn build_download_path(
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!("/download/{token}")) .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")
+1 -1
View File
@@ -558,7 +558,7 @@ pub async fn get_document_version(
#[utoipa::path( #[utoipa::path(
get, get,
path = "/download/{token}", path = "/api/download/{token}",
params(("token" = String, Path, description = "Download token")), params(("token" = String, Path, description = "Download token")),
responses((status = 200, description = "Proxied download stream or redirect")), responses((status = 200, description = "Proxied download stream or redirect")),
tag = "Documents" tag = "Documents"
+1 -1
View File
@@ -210,7 +210,7 @@ pub fn create_router(state: AppState) -> Router<()> {
); );
let download_routes = let download_routes =
Router::new().route("/download/{token}", get(documents::download_with_token)); Router::new().route("/api/download/{token}", get(documents::download_with_token));
let folders_routes = Router::new() let folders_routes = Router::new()
.route( .route(
+26 -12
View File
@@ -964,7 +964,7 @@ impl<'a> DocumentsService<'a> {
error!(error = ?err, "failed to issue asset download token"); error!(error = ?err, "failed to issue asset download token");
AppError::internal("failed to issue asset download token") AppError::internal("failed to issue asset download token")
})?; })?;
(format!("/download/{token}"), None) (format!("/api/download/{token}"), None)
} else { } else {
let storage = storage let storage = storage
.as_ref() .as_ref()
@@ -1044,19 +1044,33 @@ impl<'a> DocumentsService<'a> {
tenant_id: Uuid, tenant_id: Uuid,
document_id: Uuid, document_id: Uuid,
) -> AppResult<()> { ) -> AppResult<()> {
let now = Utc::now().naive_utc(); conn.transaction::<_, AppError, _>(|conn| {
diesel::update( let document: Document = documents::table
documents::table
.find(document_id) .find(document_id)
.filter(documents::tenant_id.eq(tenant_id)), .filter(documents::tenant_id.eq(tenant_id))
) .for_update()
.set(( .first(conn)
documents::deleted_at.eq(Some(now)), .optional()?
documents::updated_at.eq(now), .ok_or_else(AppError::not_found)?;
))
.execute(conn)?;
Ok(()) if document.deleted_at.is_some() {
return Err(AppError::conflict("document already trashed"));
}
let now = Utc::now().naive_utc();
diesel::update(
documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now),
))
.execute(conn)?;
Ok(())
})
} }
pub fn delete_document( pub fn delete_document(
+1 -3
View File
@@ -24,9 +24,7 @@ use crate::{
use super::{ use super::{
analyze::determine_thumbnail_support, analyze::determine_thumbnail_support,
taskflow::{ taskflow::{document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult},
document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult,
},
}; };
pub const THUMBNAIL_WIDTH: u32 = 512; pub const THUMBNAIL_WIDTH: u32 = 512;
+48 -4
View File
@@ -199,7 +199,7 @@ 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("/download/")); assert!(current_version.download_path.starts_with("/api/download/"));
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());
@@ -231,7 +231,7 @@ async fn upload_and_list_document() -> Result<()> {
.as_ref() .as_ref()
.expect("list current version") .expect("list current version")
.download_path .download_path
.starts_with("/download/")); .starts_with("/api/download/"));
let redirect = app.get(&current_version.download_path, None).await?; let redirect = app.get(&current_version.download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
@@ -367,7 +367,7 @@ async fn asset_detail_uses_proxy_urls_when_configured() -> Result<()> {
.url .url
.as_deref() .as_deref()
.ok_or_else(|| anyhow!("missing url"))?; .ok_or_else(|| anyhow!("missing url"))?;
assert!(url.starts_with("/download/")); assert!(url.starts_with("/api/download/"));
assert!(object.expires_at.is_none()); assert!(object.expires_at.is_none());
app.cleanup().await?; app.cleanup().await?;
@@ -1717,6 +1717,50 @@ async fn list_documents_by_status_filter() -> Result<()> {
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
} }
#[tokio::test]
async fn trash_document_requires_active_state() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "trashstate";
app.insert_user("trashstate", TestUserRole::Owner).await?;
let token = app.login_token("trashstate", password).await?;
let upload = app
.upload_document(
"/api/documents",
"trash-once.txt",
"text/plain",
b"trash",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let first = app
.post_json(
&format!("/api/documents/{}/trash", detail.document.id),
&json!({}),
Some(&token),
)
.await?;
assert_eq!(first.status(), StatusCode::NO_CONTENT);
let second = app
.post_json(
&format!("/api/documents/{}/trash", detail.document.id),
&json!({}),
Some(&token),
)
.await?;
assert_eq!(second.status(), StatusCode::CONFLICT);
app.cleanup().await?;
Ok(())
}
#[tokio::test] #[tokio::test]
async fn purge_document_removes_data() -> Result<()> { async fn purge_document_removes_data() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
@@ -2008,7 +2052,7 @@ 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("/download/")); assert!(version_detail.download_path.starts_with("/api/download/"));
assert!(version_detail.assets.is_empty()); assert!(version_detail.assets.is_empty());
app.cleanup().await?; app.cleanup().await?;
+1 -1
View File
@@ -68,7 +68,7 @@ async fn download_with_invalid_token_is_rejected() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
let app = TestApp::new().await?; let app = TestApp::new().await?;
let response = app.get("/download/not-a-token", None).await?; let response = app.get("/api/download/not-a-token", None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?; app.cleanup().await?;
+2 -2
View File
@@ -44,7 +44,7 @@ Document Assets
Downloads Downloads
--------- ---------
- GET /download/:token - Follow a one-time download token; redirects to a pre-signed URL (public token required). - GET /api/download/:token - Follow a one-time download token; redirects to a pre-signed URL (public token required).
Folders Folders
------- -------
@@ -68,4 +68,4 @@ Correspondents
- GET /api/correspondents - List correspondents with usage totals. - GET /api/correspondents - List correspondents with usage totals.
- POST /api/correspondents - Create a correspondent (name + optional metadata JSON). - POST /api/correspondents - Create a correspondent (name + optional metadata JSON).
- PATCH /api/correspondents/:id - Update name and/or metadata. - PATCH /api/correspondents/:id - Update name and/or metadata.
- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. - DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document.
@@ -274,9 +274,39 @@ const useDocumentMutations = ({
} }
try { try {
await Promise.all( const softDeleteTargets = [];
documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)), const hardDeleteTargets = [];
);
documentIds.forEach((documentId) => {
const lookupDoc =
documentLookup && typeof documentLookup.get === 'function'
? documentLookup.get(documentId)
: documentLookup?.[documentId];
if (lookupDoc && lookupDoc.deleted_at) {
hardDeleteTargets.push(documentId);
} else {
softDeleteTargets.push(documentId);
}
});
const operations = [];
if (softDeleteTargets.length) {
operations.push(
Promise.all(
softDeleteTargets.map((documentId) => api.post(`/documents/${documentId}/trash`)),
),
);
}
if (hardDeleteTargets.length) {
operations.push(
Promise.all(
hardDeleteTargets.map((documentId) => api.delete(`/documents/${documentId}`)),
),
);
}
await Promise.all(operations);
removeDocumentsFromCaches(documentIds); removeDocumentsFromCaches(documentIds);
@@ -302,6 +332,7 @@ const useDocumentMutations = ({
[ [
api, api,
token, token,
documentLookup,
removeDocumentsFromCaches, removeDocumentsFromCaches,
previewDocumentId, previewDocumentId,
closeDocumentPreview, closeDocumentPreview,