patch tags

This commit is contained in:
2025-10-12 01:52:30 +02:00
parent 1b950a8f9a
commit f30e455c2d
20 changed files with 1306 additions and 161 deletions
+129 -20
View File
@@ -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 {