patch tags
This commit is contained in:
+129
-20
@@ -5,7 +5,7 @@ use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use diesel::dsl::exists;
|
||||
use diesel::{prelude::*, PgConnection};
|
||||
use diesel::{prelude::*, select, PgConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -18,11 +18,35 @@ use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
|
||||
use crate::models::{
|
||||
Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
|
||||
};
|
||||
use crate::schema::{document_assets, document_tags, document_versions, documents, folders, tags};
|
||||
use crate::schema::{
|
||||
document_assets, document_tags, document_versions, documents, folders,
|
||||
refresh_tokens::dsl as refresh_dsl, tags,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
|
||||
|
||||
fn inline_content_disposition(filename: &str) -> Option<String> {
|
||||
if filename.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sanitized: String = filename
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'"' | '\\' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let encoded =
|
||||
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
|
||||
Some(format!(
|
||||
"inline; filename=\"{}\"; filename*=UTF-8''{}",
|
||||
sanitized, encoded
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DocumentListQuery {
|
||||
pub folder_id: Option<Uuid>,
|
||||
@@ -69,6 +93,7 @@ pub struct DocumentResponse {
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<TagResponse>,
|
||||
pub thumbnail: Option<DocumentAssetResponse>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
@@ -182,6 +207,7 @@ pub struct AssignTagsRequest {
|
||||
pub async fn list_documents(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<DocumentListQuery>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
@@ -210,14 +236,18 @@ pub async fn list_documents(
|
||||
|
||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
||||
|
||||
let response = docs
|
||||
.into_iter()
|
||||
.map(|doc| {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
||||
to_document_response(doc, tags, thumbnail)
|
||||
})
|
||||
.collect();
|
||||
let mut response = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
||||
response.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
thumbnail,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
@@ -225,6 +255,7 @@ pub async fn list_documents(
|
||||
pub async fn get_document(
|
||||
State(state): State<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<DocumentDetailResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
@@ -249,7 +280,13 @@ pub async fn get_document(
|
||||
.cloned();
|
||||
|
||||
Ok(Json(DocumentDetailResponse {
|
||||
document: to_document_response(doc, tags_map.get(&document_id).cloned(), thumbnail),
|
||||
document: to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags_map.get(&document_id).cloned(),
|
||||
thumbnail,
|
||||
)?,
|
||||
current_version: to_version_response(current_version),
|
||||
assets,
|
||||
}))
|
||||
@@ -257,7 +294,7 @@ pub async fn get_document(
|
||||
|
||||
pub async fn upload_document(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
user: AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<(StatusCode, Json<DocumentDetailResponse>)> {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
@@ -335,7 +372,7 @@ pub async fn upload_document(
|
||||
metadata,
|
||||
};
|
||||
|
||||
let outcome = match process_upload(&state, request).await {
|
||||
let outcome = match process_upload(&state, request, user.user_id).await {
|
||||
Ok(outcome) => {
|
||||
info!(
|
||||
document_id = %outcome.detail.document.id,
|
||||
@@ -527,6 +564,54 @@ pub async fn download_document(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn download_with_token(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_download_token(&token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let doc: Document = documents::table.find(claims.doc_id).first(&mut conn)?;
|
||||
if doc.deleted_at.is_some() {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(claims.doc_id))
|
||||
.filter(document_versions::version_number.eq(doc.current_version))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let has_active_refresh: bool = select(exists(
|
||||
refresh_dsl::refresh_tokens
|
||||
.filter(refresh_dsl::user_id.eq(claims.user_id))
|
||||
.filter(refresh_dsl::revoked_at.is_null())
|
||||
.filter(refresh_dsl::expires_at.gt(now)),
|
||||
))
|
||||
.get_result(&mut conn)?;
|
||||
|
||||
if !has_active_refresh {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
let presigned_url = state
|
||||
.storage
|
||||
.presign_get_object(
|
||||
&version.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
|
||||
|
||||
Ok(axum::response::Redirect::temporary(&presigned_url))
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
State(state): State<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
@@ -757,7 +842,11 @@ pub async fn remove_tag(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<UploadOutcome> {
|
||||
async fn process_upload(
|
||||
state: &AppState,
|
||||
request: UploadRequest,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<UploadOutcome> {
|
||||
let UploadRequest {
|
||||
bytes,
|
||||
original_name,
|
||||
@@ -824,7 +913,7 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
|
||||
|
||||
return Ok(UploadOutcome {
|
||||
detail: DocumentDetailResponse {
|
||||
document: to_document_response(document, tags, thumbnail),
|
||||
document: to_document_response(state, user_id, document, tags, thumbnail)?,
|
||||
current_version: to_version_response(version),
|
||||
assets,
|
||||
},
|
||||
@@ -833,9 +922,16 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
|
||||
}
|
||||
}
|
||||
|
||||
let content_disposition = inline_content_disposition(&original_name);
|
||||
|
||||
state
|
||||
.storage
|
||||
.put_object(&s3_key, bytes.clone(), content_type.clone())
|
||||
.put_object(
|
||||
&s3_key,
|
||||
bytes.clone(),
|
||||
content_type.clone(),
|
||||
content_disposition.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(error = %err, key = %s3_key, "failed to store document");
|
||||
@@ -888,7 +984,7 @@ async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult<U
|
||||
};
|
||||
|
||||
let detail = DocumentDetailResponse {
|
||||
document: to_document_response(document, None, None),
|
||||
document: to_document_response(state, user_id, document, None, None)?,
|
||||
current_version: to_version_response(version.clone()),
|
||||
assets: Vec::new(),
|
||||
};
|
||||
@@ -1019,11 +1115,15 @@ pub(crate) async fn load_primary_thumbnails(
|
||||
}
|
||||
|
||||
pub(crate) fn to_document_response(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
doc: Document,
|
||||
tags: Option<Vec<Tag>>,
|
||||
thumbnail: Option<DocumentAssetResponse>,
|
||||
) -> DocumentResponse {
|
||||
DocumentResponse {
|
||||
) -> AppResult<DocumentResponse> {
|
||||
let download_path = build_download_path(state, doc.id, user_id)?;
|
||||
|
||||
Ok(DocumentResponse {
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
title: doc.title,
|
||||
@@ -1042,7 +1142,16 @@ pub(crate) fn to_document_response(
|
||||
.map(TagResponse::from)
|
||||
.collect(),
|
||||
thumbnail,
|
||||
}
|
||||
download_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> AppResult<String> {
|
||||
state
|
||||
.jwt
|
||||
.generate_download_token(document_id, user_id)
|
||||
.map(|token| format!("/download/{token}"))
|
||||
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
||||
}
|
||||
|
||||
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
||||
|
||||
Reference in New Issue
Block a user