1153 lines
39 KiB
Rust
1153 lines
39 KiB
Rust
use std::{collections::HashSet, time::Duration};
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::{Json, Multipart, Path, Query, State};
|
|
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::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn};
|
|
use crate::documents::asset::{
|
|
asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse,
|
|
DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink,
|
|
};
|
|
#[allow(unused_imports)]
|
|
use crate::error::ApiErrorResponse;
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse};
|
|
use crate::models::{Document, DocumentAsset, DocumentVersion};
|
|
use crate::schema::{
|
|
document_assets, document_versions, documents, user_sessions::dsl as session_dsl,
|
|
};
|
|
use crate::services::correspondents::{
|
|
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
|
|
BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService,
|
|
};
|
|
use crate::services::documents::{
|
|
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
|
|
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
|
|
DocumentResponse, DocumentUploadOutcome, DocumentUploadRequest, DocumentsService,
|
|
};
|
|
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;
|
|
|
|
#[derive(Deserialize, IntoParams, ToSchema)]
|
|
#[into_params(parameter_in = Query)]
|
|
pub struct AssetRequestQuery {
|
|
#[serde(default)]
|
|
#[schema(default = false)]
|
|
pub force: bool,
|
|
}
|
|
|
|
#[derive(Deserialize, IntoParams, ToSchema)]
|
|
#[into_params(parameter_in = Query)]
|
|
pub struct DocumentCheckQuery {
|
|
pub checksum: String,
|
|
}
|
|
|
|
#[derive(ToSchema)]
|
|
pub struct UploadDocumentForm {
|
|
#[schema(value_type = String, format = Binary)]
|
|
pub file: String,
|
|
#[schema(nullable)]
|
|
pub folder_id: Option<Uuid>,
|
|
#[schema(nullable, value_type = Object)]
|
|
pub metadata: Option<Value>,
|
|
#[schema(nullable)]
|
|
pub title: Option<String>,
|
|
#[schema(nullable, value_type = Vec<Uuid>)]
|
|
pub tag_ids: Option<Vec<Uuid>>,
|
|
#[schema(nullable, value_type = Vec<CorrespondentAssignmentInput>)]
|
|
pub correspondents: Option<Vec<CorrespondentAssignmentInput>>,
|
|
#[schema(nullable, example = "2024-01-01T00:00:00Z")]
|
|
pub issued_at: Option<String>,
|
|
#[schema(nullable, default = true)]
|
|
pub skip_existing: Option<bool>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct MoveDocumentRequest {
|
|
pub folder_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Deserialize, ToSchema)]
|
|
pub struct RestoreDocumentRequest {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub folder_id: Option<Uuid>,
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/documents",
|
|
params(DocumentListQuery),
|
|
responses((status = 200, description = "List documents", body = [DocumentResponse])),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn list_documents(
|
|
State(state): State<AppState>,
|
|
Query(params): Query<DocumentListQuery>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<Vec<DocumentResponse>>> {
|
|
let service = DocumentsService::new(&state);
|
|
let documents = service
|
|
.list_documents(&mut conn, tenant_id, user_id, params)
|
|
.await?;
|
|
ok_json(documents)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/documents/check",
|
|
params(DocumentCheckQuery),
|
|
responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn check_document(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<DocumentCheckQuery>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<DocumentCheckResponse>> {
|
|
let checksum_raw = query.checksum.trim();
|
|
if checksum_raw.is_empty() {
|
|
return Err(AppError::bad_request("checksum must not be empty"));
|
|
}
|
|
|
|
let checksum = checksum_raw.to_ascii_lowercase();
|
|
if !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) {
|
|
return Err(AppError::bad_request(
|
|
"checksum must be a hex-encoded string",
|
|
));
|
|
}
|
|
|
|
let service = DocumentsService::new(&state);
|
|
let response = service.check_document(&mut conn, tenant_id, &checksum)?;
|
|
ok_json(response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/documents/{id}",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
responses((status = 200, description = "Document detail", body = DocumentDetailResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn get_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
|
|
let service = DocumentsService::new(&state);
|
|
let detail = service
|
|
.get_document_detail(&mut conn, tenant_id, user_id, document_id)
|
|
.await?;
|
|
ok_json(detail)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents",
|
|
request_body = UploadDocumentForm,
|
|
responses(
|
|
(status = 201, description = "Document created", body = DocumentDetailResponse),
|
|
(status = 200, description = "Existing document reused", body = DocumentDetailResponse),
|
|
(
|
|
status = 409,
|
|
description = "Document with identical contents already exists",
|
|
body = ApiErrorResponse
|
|
)
|
|
),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn upload_document(
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
mut multipart: Multipart,
|
|
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
|
|
let tenant_id = tenant_id;
|
|
let user_id = user_id;
|
|
let mut file_bytes: Option<Vec<u8>> = None;
|
|
let mut original_name: Option<String> = None;
|
|
let mut mime_type: Option<String> = None;
|
|
let mut folder_id: Option<Uuid> = None;
|
|
let mut metadata: Value = Value::Object(Default::default());
|
|
let mut tag_ids: Vec<Uuid> = Vec::new();
|
|
let mut correspondents: Vec<CorrespondentAssignmentInput> = Vec::new();
|
|
let mut issued_at_override: Option<NaiveDateTime> = None;
|
|
let mut skip_if_existing = true;
|
|
let mut title_override: Option<String> = None;
|
|
|
|
while let Some(field) = multipart.next_field().await.map_err(|err| {
|
|
let msg = format!("invalid multipart data: {err}");
|
|
error!(error = %err, "invalid multipart data");
|
|
AppError::bad_request(msg)
|
|
})? {
|
|
let name = field.name().map(|n| n.to_string());
|
|
match name.as_deref() {
|
|
Some("file") => {
|
|
let file_name = field.file_name().map(|n| n.to_string());
|
|
original_name = file_name.clone();
|
|
mime_type = field.content_type().map(|mime| mime.to_string());
|
|
let data = field.bytes().await.map_err(|err| {
|
|
let msg = format!("failed to read file bytes: {err}");
|
|
error!(error = %err, "failed to read file bytes");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
file_bytes = Some(data.to_vec());
|
|
}
|
|
Some("folder_id") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid folder id: {err}");
|
|
error!(error = %err, "invalid folder id");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
if !value.trim().is_empty() {
|
|
let parsed = Uuid::parse_str(value.trim())
|
|
.map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?;
|
|
folder_id = Some(parsed);
|
|
}
|
|
}
|
|
Some("metadata") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid metadata: {err}");
|
|
error!(error = %err, "invalid metadata payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
metadata = serde_json::from_str(&value).map_err(|err| {
|
|
let msg = format!("metadata must be valid JSON: {err}");
|
|
error!(error = %err, "metadata parse failure");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
}
|
|
Some("title") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid title: {err}");
|
|
error!(error = %err, "invalid title payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
let trimmed = value.trim();
|
|
if !trimmed.is_empty() {
|
|
title_override = Some(trimmed.to_string());
|
|
}
|
|
}
|
|
Some("tag_ids") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid tag_ids: {err}");
|
|
error!(error = %err, "invalid tag_ids payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
let parsed: Vec<String> = serde_json::from_str(&value).map_err(|err| {
|
|
let msg = format!("tag_ids must be a JSON array of UUID strings: {err}");
|
|
error!(error = %err, "invalid tag_ids json");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
let mut set = HashSet::new();
|
|
for raw in parsed {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
let uuid = Uuid::parse_str(trimmed)
|
|
.map_err(|_| AppError::bad_request("tag_ids must contain valid UUIDs"))?;
|
|
set.insert(uuid);
|
|
}
|
|
tag_ids = set.into_iter().collect();
|
|
}
|
|
Some("correspondents") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid correspondents: {err}");
|
|
error!(error = %err, "invalid correspondents payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
correspondents = serde_json::from_str(&value).map_err(|err| {
|
|
let msg = format!(
|
|
"correspondents must be a JSON array of {{correspondent_id}} objects: {err}"
|
|
);
|
|
error!(error = %err, "invalid correspondents json");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
}
|
|
Some("issued_at") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid issued_at: {err}");
|
|
error!(error = %err, "invalid issued_at payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
let trimmed = value.trim();
|
|
if !trimmed.is_empty() {
|
|
let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| {
|
|
let msg = format!("issued_at must be an RFC3339 timestamp: {err}");
|
|
error!(error = %err, "invalid issued_at format");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
issued_at_override = Some(parsed.naive_utc());
|
|
}
|
|
}
|
|
Some("skip_existing") => {
|
|
let value = field.text().await.map_err(|err| {
|
|
let msg = format!("invalid skip_existing flag: {err}");
|
|
error!(error = %err, "invalid skip_existing payload");
|
|
AppError::bad_request(msg)
|
|
})?;
|
|
skip_if_existing = matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes"
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let file_bytes = file_bytes.ok_or_else(|| {
|
|
error!("upload rejected: missing file field");
|
|
AppError::bad_request("file field is required")
|
|
})?;
|
|
|
|
if file_bytes.is_empty() {
|
|
error!("upload rejected: empty file payload");
|
|
return Err(AppError::bad_request("file field must not be empty"));
|
|
}
|
|
let original_name = original_name.ok_or_else(|| {
|
|
error!("upload rejected: missing original filename");
|
|
AppError::bad_request("filename is required")
|
|
})?;
|
|
let original_name_for_log = original_name.clone();
|
|
|
|
let request = DocumentUploadRequest {
|
|
bytes: file_bytes,
|
|
original_name,
|
|
mime_type,
|
|
folder_id,
|
|
metadata,
|
|
title_override,
|
|
tag_ids,
|
|
correspondents,
|
|
issued_at_override,
|
|
skip_if_existing,
|
|
};
|
|
|
|
let service = DocumentsService::new(&state);
|
|
let outcome = match service
|
|
.upload_document(&mut conn, tenant_id, user_id, request)
|
|
.await
|
|
{
|
|
Ok(outcome) => outcome,
|
|
Err(err) => {
|
|
error!(error = ?err, original_name = %original_name_for_log, "document upload failed");
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
let response = match outcome {
|
|
DocumentUploadOutcome::Created(detail) => {
|
|
info!(
|
|
document_id = %detail.document.id,
|
|
original_name = %detail.document.original_name,
|
|
created = true,
|
|
reused_existing = false,
|
|
"document upload succeeded",
|
|
);
|
|
created_json(detail)?
|
|
}
|
|
DocumentUploadOutcome::Reused(detail) => {
|
|
info!(
|
|
document_id = %detail.document.id,
|
|
original_name = %detail.document.original_name,
|
|
created = false,
|
|
reused_existing = true,
|
|
"document upload succeeded",
|
|
);
|
|
ok_json(detail)?
|
|
}
|
|
};
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/{id}/assets",
|
|
params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery),
|
|
responses((status = 202, description = "Asset generation requested")),
|
|
tag = "Assets"
|
|
)]
|
|
pub async fn request_document_assets(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
Query(query): Query<AssetRequestQuery>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<StatusCode> {
|
|
let service = DocumentsService::new(&state);
|
|
service.request_document_assets(&mut conn, tenant_id, document_id, query.force)?;
|
|
Ok(StatusCode::ACCEPTED)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/bulk/reanalyze",
|
|
request_body = BulkReanalyzeSelectionRequest,
|
|
responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn reanalyze_selected_documents(
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<BulkReanalyzeSelectionRequest>,
|
|
) -> AppResult<JsonResponse<BulkReanalyzeResponse>> {
|
|
let BulkReanalyzeSelectionRequest {
|
|
mut document_ids,
|
|
force,
|
|
} = payload;
|
|
|
|
let service = DocumentsService::new(&state);
|
|
let queued = service.reanalyze_documents(&mut conn, tenant_id, &mut document_ids, force)?;
|
|
accepted_json(BulkReanalyzeResponse { queued })
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/documents/{id}/assets",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])),
|
|
tag = "Assets"
|
|
)]
|
|
pub async fn list_document_assets(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<Vec<DocumentAssetResponse>>> {
|
|
let service = DocumentsService::new(&state);
|
|
let assets = service
|
|
.list_document_assets(&mut conn, tenant_id, user_id, document_id)
|
|
.await?;
|
|
ok_json(assets)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/assets/{asset_id}",
|
|
params(("asset_id" = Uuid, Path, description = "Asset ID")),
|
|
responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)),
|
|
tag = "Assets"
|
|
)]
|
|
pub async fn get_document_asset(
|
|
State(state): State<AppState>,
|
|
Path(asset_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<DocumentAssetDetailResponse>> {
|
|
let service = DocumentsService::new(&state);
|
|
let detail = service
|
|
.get_document_asset(conn, tenant_id, user_id, asset_id)
|
|
.await?;
|
|
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(
|
|
get,
|
|
path = "/api/documents/{id}/versions",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn list_document_versions(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<Vec<DocumentVersionResponse>>> {
|
|
let service = DocumentsService::new(&state);
|
|
let versions = service.list_document_versions(&mut conn, tenant_id, document_id)?;
|
|
ok_json(versions)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/documents/{id}/versions/{version_id}",
|
|
params(
|
|
("id" = Uuid, Path, description = "Document ID"),
|
|
("version_id" = Uuid, Path, description = "Version ID"),
|
|
),
|
|
responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn get_document_version(
|
|
State(state): State<AppState>,
|
|
Path((document_id, version_id)): Path<(Uuid, Uuid)>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<JsonResponse<DocumentVersionDetailResponse>> {
|
|
let service = DocumentsService::new(&state);
|
|
let detail = service
|
|
.get_document_version(&mut conn, tenant_id, user_id, document_id, version_id)
|
|
.await?;
|
|
ok_json(detail)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/download/{token}",
|
|
params(("token" = String, Path, description = "Download token")),
|
|
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>,
|
|
headers: HeaderMap,
|
|
) -> AppResult<Response> {
|
|
let claims = state
|
|
.jwt
|
|
.verify_download_token(&token)
|
|
.map_err(|_| AppError::unauthorized())?;
|
|
|
|
let mut conn = state.db_for_tenant(claims.tenant_id)?;
|
|
ensure_active_tenant_with_conn(&mut conn, claims.tenant_id)?;
|
|
|
|
let now = Utc::now().naive_utc();
|
|
let has_active_refresh: bool = select(exists(
|
|
session_dsl::user_sessions
|
|
.filter(session_dsl::user_id.eq(claims.user_id))
|
|
.filter(session_dsl::tenant_id.eq(claims.tenant_id))
|
|
.filter(session_dsl::revoked_at.is_null())
|
|
.filter(session_dsl::expires_at.gt(now)),
|
|
))
|
|
.get_result(&mut conn)?;
|
|
|
|
if !has_active_refresh {
|
|
return Err(AppError::unauthorized());
|
|
}
|
|
|
|
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 version: DocumentVersion = document_versions::table
|
|
.find(version_id)
|
|
.filter(document_versions::document_id.eq(doc_id))
|
|
.first(&mut conn)?;
|
|
|
|
drop(conn);
|
|
|
|
let storage = state.storage_for_tenant(claims.tenant_id)?;
|
|
let disposition = inline_content_disposition(&doc.filename);
|
|
|
|
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.mime_type.as_deref(),
|
|
Some(version.id.to_string()),
|
|
)
|
|
.await
|
|
}
|
|
DownloadSubject::Asset { asset_id } => {
|
|
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_disposition(&asset);
|
|
|
|
if !state.config.proxy_downloads {
|
|
let presigned_url = storage
|
|
.presign_get_object(
|
|
&asset.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,
|
|
&asset.s3_key,
|
|
disposition.as_deref(),
|
|
headers.get(header::RANGE).cloned(),
|
|
Some(asset.mime_type.as_str()),
|
|
Some(asset.id.to_string()),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/{id}/trash",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
responses((status = 204, description = "Document deleted")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn trash_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<impl IntoResponse> {
|
|
let service = DocumentsService::new(&state);
|
|
service.trash_document(&mut conn, tenant_id, document_id)?;
|
|
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}",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
responses((status = 202, description = "Document purge scheduled")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn delete_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<impl IntoResponse> {
|
|
let service = DocumentsService::new(&state);
|
|
service.delete_document(&mut conn, tenant_id, document_id)?;
|
|
|
|
Ok(StatusCode::ACCEPTED)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/api/documents/{id}",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
request_body = crate::services::documents::UpdateDocumentRequest,
|
|
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn update_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<Value>,
|
|
) -> AppResult<JsonResponse<DocumentDetailResponse>> {
|
|
let service = DocumentsService::new(&state);
|
|
let detail = service
|
|
.update_document(&mut conn, tenant_id, user_id, document_id, payload)
|
|
.await?;
|
|
ok_json(detail)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/{id}/restore",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
request_body = RestoreDocumentRequest,
|
|
responses((status = 204, description = "Document restored")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn restore_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<RestoreDocumentRequest>,
|
|
) -> AppResult<StatusCode> {
|
|
let service = DocumentsService::new(&state);
|
|
service.restore_document(&mut conn, tenant_id, document_id, payload.folder_id)?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/api/documents/{id}/folder",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
request_body = MoveDocumentRequest,
|
|
responses((status = 204, description = "Document moved")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn move_document(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<MoveDocumentRequest>,
|
|
) -> AppResult<impl IntoResponse> {
|
|
let service = DocumentsService::new(&state);
|
|
service.move_document(&mut conn, tenant_id, document_id, payload.folder_id)?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/bulk/move",
|
|
request_body = BulkMoveRequest,
|
|
responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn bulk_move_documents(
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<BulkMoveRequest>,
|
|
) -> AppResult<JsonResponse<BulkMoveResponse>> {
|
|
let BulkMoveRequest {
|
|
document_ids,
|
|
folder_id,
|
|
} = payload;
|
|
|
|
let service = DocumentsService::new(&state);
|
|
let updated = service.bulk_move_documents(&mut conn, tenant_id, document_ids, folder_id)?;
|
|
ok_json(BulkMoveResponse { updated })
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/{id}/correspondents",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
request_body = AssignCorrespondentsRequest,
|
|
responses((status = 204, description = "Correspondents assigned")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn assign_correspondents(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<AssignCorrespondentsRequest>,
|
|
) -> AppResult<StatusCode> {
|
|
let service = CorrespondentsService::new(&state);
|
|
service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload)?;
|
|
no_content()
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/bulk/correspondents",
|
|
request_body = BulkCorrespondentsRequest,
|
|
responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn bulk_assign_correspondents(
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<BulkCorrespondentsRequest>,
|
|
) -> AppResult<JsonResponse<BulkCorrespondentResponse>> {
|
|
let service = CorrespondentsService::new(&state);
|
|
let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?;
|
|
ok_json(response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/documents/{id}/correspondents/{correspondent_id}",
|
|
params(
|
|
("id" = Uuid, Path, description = "Document ID"),
|
|
("correspondent_id" = Uuid, Path, description = "Correspondent ID")
|
|
),
|
|
responses((status = 204, description = "Correspondent removed")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn remove_correspondent(
|
|
State(state): State<AppState>,
|
|
Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<StatusCode> {
|
|
let service = CorrespondentsService::new(&state);
|
|
service.remove_from_document(&mut conn, tenant_id, document_id, correspondent_id)?;
|
|
no_content()
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/{id}/tags",
|
|
params(("id" = Uuid, Path, description = "Document ID")),
|
|
request_body = AssignTagsRequest,
|
|
responses((status = 204, description = "Tags assigned")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn assign_tags(
|
|
State(state): State<AppState>,
|
|
Path(document_id): Path<Uuid>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<AssignTagsRequest>,
|
|
) -> AppResult<impl IntoResponse> {
|
|
let service = TagsService::new(&state);
|
|
service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload.tag_ids)?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/documents/bulk/tags",
|
|
request_body = BulkTagRequest,
|
|
responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn bulk_update_tags(
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
user_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
Json(payload): Json<BulkTagRequest>,
|
|
) -> AppResult<JsonResponse<BulkTagResponse>> {
|
|
let service = TagsService::new(&state);
|
|
let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?;
|
|
ok_json(response)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/documents/{id}/tags/{tag_id}",
|
|
params(
|
|
("id" = Uuid, Path, description = "Document ID"),
|
|
("tag_id" = Uuid, Path, description = "Tag ID")
|
|
),
|
|
responses((status = 204, description = "Tag removed")),
|
|
tag = "Documents"
|
|
)]
|
|
pub async fn remove_tag(
|
|
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
|
|
State(state): State<AppState>,
|
|
TenantScopedConn {
|
|
mut conn,
|
|
tenant_id,
|
|
..
|
|
}: TenantScopedConn,
|
|
) -> AppResult<StatusCode> {
|
|
let service = TagsService::new(&state);
|
|
service.remove_from_document(&mut conn, tenant_id, document_id, tag_id)?;
|
|
no_content()
|
|
}
|
|
|
|
#[derive(utoipa::OpenApi)]
|
|
#[openapi(
|
|
paths(
|
|
crate::routes::documents::list_documents,
|
|
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,
|
|
crate::routes::documents::restore_document,
|
|
crate::routes::documents::download_with_token,
|
|
crate::routes::documents::move_document,
|
|
crate::routes::documents::assign_tags,
|
|
crate::routes::documents::remove_tag,
|
|
crate::routes::documents::bulk_move_documents,
|
|
crate::routes::documents::bulk_update_tags,
|
|
crate::routes::documents::bulk_assign_correspondents,
|
|
crate::routes::documents::assign_correspondents,
|
|
crate::routes::documents::remove_correspondent,
|
|
crate::routes::documents::reanalyze_selected_documents,
|
|
crate::routes::documents::list_document_assets,
|
|
crate::routes::documents::request_document_assets,
|
|
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,
|
|
crate::services::documents::DocumentStatusFilter,
|
|
crate::routes::documents::AssetRequestQuery,
|
|
crate::routes::documents::DocumentCheckQuery,
|
|
crate::routes::documents::DocumentCheckResponse,
|
|
crate::services::documents::DocumentResponse,
|
|
crate::services::documents::DocumentDetailResponse,
|
|
crate::routes::documents::DocumentMetadataUpdate,
|
|
crate::services::documents::TagResponse,
|
|
crate::routes::documents::CorrespondentAssignmentInput,
|
|
crate::routes::documents::AssignCorrespondentsRequest,
|
|
crate::routes::documents::BulkCorrespondentAction,
|
|
crate::routes::documents::BulkCorrespondentsRequest,
|
|
crate::routes::documents::BulkCorrespondentResponse,
|
|
crate::routes::documents::BulkMoveRequest,
|
|
crate::routes::documents::BulkMoveResponse,
|
|
crate::routes::documents::BulkTagAction,
|
|
crate::routes::documents::BulkTagRequest,
|
|
crate::routes::documents::BulkTagResponse,
|
|
crate::routes::documents::AssignTagsRequest,
|
|
crate::routes::documents::MoveDocumentRequest,
|
|
crate::routes::documents::BulkReanalyzeSelectionRequest,
|
|
crate::routes::documents::BulkReanalyzeResponse,
|
|
crate::routes::documents::UploadDocumentForm,
|
|
crate::documents::asset::DocumentVersionResponse,
|
|
crate::documents::asset::DocumentVersionDetailResponse,
|
|
crate::documents::asset::DocumentAssetResponse,
|
|
crate::documents::asset::DocumentAssetDetailResponse,
|
|
crate::documents::asset::DownloadLink,
|
|
crate::documents::correspondents::DocumentCorrespondentResponse,
|
|
crate::error::ApiErrorResponse,
|
|
))
|
|
)]
|
|
pub struct DocumentsApiDoc;
|