proxy downloads

This commit is contained in:
2025-11-09 12:01:19 +01:00
parent d24d2c9249
commit 81571e4c64
9 changed files with 383 additions and 71 deletions
+165 -34
View File
@@ -1,26 +1,31 @@
use std::{collections::HashSet, time::Duration};
use axum::body::Body;
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, select};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::Value;
use tracing::{error, info};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::auth::TenantScopedConn;
use crate::auth::{jwt::DownloadSubject, TenantScopedConn};
use crate::documents::asset::{
DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse,
DocumentVersionResponse,
asset_object_disposition, DocumentAssetDetailResponse, DocumentAssetResponse,
DocumentVersionDetailResponse, DocumentVersionResponse,
};
use crate::error::{AppError, AppResult};
use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse};
use crate::models::{Document, DocumentVersion};
use crate::schema::{document_versions, documents, user_sessions::dsl as session_dsl};
use crate::models::{Document, DocumentAsset, DocumentAssetObject, DocumentVersion};
use crate::schema::{
document_asset_objects, document_assets, document_versions, documents,
user_sessions::dsl as session_dsl,
};
use crate::services::correspondents::{
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService,
@@ -34,6 +39,7 @@ use crate::services::tags::{
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, TagsService,
};
use crate::state::AppState;
use crate::storage::TenantStorage;
use crate::utils::{error::StorageResultExt, http::inline_content_disposition};
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
@@ -479,6 +485,7 @@ pub async fn get_document_asset(
TenantScopedConn {
conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<DocumentAssetDetailResponse>> {
@@ -492,7 +499,7 @@ pub async fn get_document_asset(
}
let service = DocumentsService::new(&state);
let detail = service
.get_document_asset(conn, tenant_id, asset_id, start, limit)
.get_document_asset(conn, tenant_id, user_id, asset_id, start, limit)
.await?;
ok_json(detail)
}
@@ -549,13 +556,14 @@ pub async fn get_document_version(
get,
path = "/download/{token}",
params(("token" = String, Path, description = "Download token")),
responses((status = 302, description = "Redirect to pre-signed URL")),
responses((status = 200, description = "Proxied download stream or redirect")),
tag = "Documents"
)]
pub async fn download_with_token(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<impl IntoResponse> {
headers: HeaderMap,
) -> AppResult<Response> {
let claims = state
.jwt
.verify_download_token(&token)
@@ -563,18 +571,6 @@ pub async fn download_with_token(
let mut conn = state.db_for_tenant(claims.tenant_id)?;
let doc: Document = documents::table
.find(claims.doc_id)
.filter(documents::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.find(doc.current_version_id)
.first(&mut conn)?;
let now = Utc::now().naive_utc();
let has_active_refresh: bool = select(exists(
session_dsl::user_sessions
@@ -589,22 +585,85 @@ pub async fn download_with_token(
return Err(AppError::unauthorized());
}
drop(conn);
match &claims.subject {
DownloadSubject::Document { doc_id, version_id } => {
let doc_id = *doc_id;
let version_id = *version_id;
let doc: Document = documents::table
.find(doc_id)
.filter(documents::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let storage = state.storage_for_tenant(claims.tenant_id)?;
let version: DocumentVersion = document_versions::table
.find(version_id)
.filter(document_versions::document_id.eq(doc_id))
.first(&mut conn)?;
let disposition = inline_content_disposition(&doc.filename);
drop(conn);
let presigned_url = storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
disposition.as_deref(),
)
.await
.storage_context("failed to generate download URL")?;
let storage = state.storage_for_tenant(claims.tenant_id)?;
let disposition = inline_content_disposition(&doc.filename);
Ok(axum::response::Redirect::temporary(&presigned_url))
if !state.config.proxy_downloads {
let presigned_url = storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
disposition.as_deref(),
)
.await
.storage_context("failed to generate download URL")?;
return Ok(axum::response::Redirect::temporary(&presigned_url).into_response());
}
proxy_storage_object(
storage,
&version.s3_key,
disposition.as_deref(),
headers.get(header::RANGE).cloned(),
doc.content_type.as_deref(),
Some(version.id.to_string()),
)
.await
}
DownloadSubject::AssetObject {
asset_id,
object_id,
} => {
if !state.config.proxy_downloads {
return Err(AppError::not_found());
}
let object: DocumentAssetObject = document_asset_objects::table
.find(*object_id)
.filter(document_asset_objects::asset_id.eq(*asset_id))
.filter(document_asset_objects::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
let asset: DocumentAsset = document_assets::table
.find(*asset_id)
.filter(document_assets::tenant_id.eq(claims.tenant_id))
.first(&mut conn)?;
drop(conn);
let storage = state.storage_for_tenant(claims.tenant_id)?;
let disposition = asset_object_disposition(&asset, &object);
proxy_storage_object(
storage,
&object.s3_key,
disposition.as_deref(),
headers.get(header::RANGE).cloned(),
Some(asset.mime_type.as_str()),
Some(object.id.to_string()),
)
.await
}
}
}
#[utoipa::path(
@@ -628,6 +687,78 @@ pub async fn trash_document(
Ok(StatusCode::NO_CONTENT)
}
async fn proxy_storage_object(
storage: TenantStorage,
key: &str,
response_disposition: Option<&str>,
range_header: Option<HeaderValue>,
fallback_content_type: Option<&str>,
etag: Option<String>,
) -> AppResult<Response> {
let url = storage
.presign_get_object(
key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
response_disposition,
)
.await
.storage_context("failed to generate download URL")?;
let client = reqwest::Client::new();
let mut request = client.get(url.clone());
if let Some(range) = range_header {
request = request.header(header::RANGE, range);
}
let upstream = request.send().await.map_err(|err| {
tracing::error!(error = ?err, "failed to fetch document stream");
AppError::internal("failed to fetch document stream")
})?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
tracing::error!(status = %status, "upstream download returned error status");
return Err(AppError::internal("failed to fetch document stream"));
}
let mut builder = Response::builder().status(status);
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type);
} else if let Some(fallback) = fallback_content_type {
builder = builder.header(header::CONTENT_TYPE, fallback);
}
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
builder = builder.header(header::CONTENT_LENGTH, content_length);
}
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
builder = builder.header(header::CONTENT_RANGE, range);
}
builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = response_disposition {
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
}
if let Some(etag_value) = etag {
builder = builder.header(header::ETAG, format!("\"{}\"", etag_value));
}
let stream = upstream
.bytes_stream()
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream);
builder.body(body).map_err(|err| {
tracing::error!(error = ?err, "failed to build proxied response");
AppError::internal("failed to build proxied response")
})
}
#[utoipa::path(
delete,
path = "/api/documents/{id}",