/api/download
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¤t_version.download_path, None).await?;
|
let redirect = app.get(¤t_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?;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user