patch tags
This commit is contained in:
Generated
+1
@@ -2132,6 +2132,7 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"once_cell",
|
||||
"pdfium-render",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -39,6 +39,7 @@ pdfium-render = "0.8"
|
||||
mime_guess = "2.0"
|
||||
tempfile = "3.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
percent-encoding = "2.3"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct JwtService {
|
||||
issuer: String,
|
||||
audience: String,
|
||||
expiry: Duration,
|
||||
download_audience: String,
|
||||
download_expiry: Duration,
|
||||
}
|
||||
|
||||
impl JwtService {
|
||||
@@ -23,6 +25,8 @@ impl JwtService {
|
||||
issuer: config.jwt_issuer.clone(),
|
||||
audience: config.jwt_audience.clone(),
|
||||
expiry: Duration::minutes(config.jwt_expiry_minutes),
|
||||
download_audience: config.download_token_audience.clone(),
|
||||
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +53,29 @@ impl JwtService {
|
||||
let data = decode::<Claims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
|
||||
let now = Utc::now();
|
||||
let exp = now + self.download_expiry;
|
||||
let claims = DownloadClaims {
|
||||
doc_id: document_id,
|
||||
user_id,
|
||||
iss: self.issuer.clone(),
|
||||
aud: self.download_audience.clone(),
|
||||
iat: now.timestamp() as usize,
|
||||
exp: exp.timestamp() as usize,
|
||||
};
|
||||
|
||||
Ok(encode(&Header::default(), &claims, &self.encoding)?)
|
||||
}
|
||||
|
||||
pub fn verify_download_token(&self, token: &str) -> Result<DownloadClaims> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[self.download_audience.clone()]);
|
||||
validation.set_issuer(&[self.issuer.clone()]);
|
||||
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -61,3 +88,13 @@ pub struct Claims {
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadClaims {
|
||||
pub doc_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub iat: usize,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct AppConfig {
|
||||
pub jwt_issuer: String,
|
||||
pub jwt_audience: String,
|
||||
pub jwt_expiry_minutes: i64,
|
||||
pub download_token_audience: String,
|
||||
pub download_token_expiry_minutes: i64,
|
||||
pub refresh_token_expiry_days: i64,
|
||||
pub refresh_cookie_secure: bool,
|
||||
pub refresh_cookie_domain: Option<String>,
|
||||
@@ -40,6 +42,12 @@ impl AppConfig {
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("JWT_EXPIRY_MINUTES must be an integer")?;
|
||||
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
|
||||
.unwrap_or_else(|_| "paperless-neo-download".to_string());
|
||||
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
|
||||
.unwrap_or_else(|_| "60".to_string())
|
||||
.parse()
|
||||
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
|
||||
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
|
||||
.unwrap_or_else(|_| "30".to_string())
|
||||
.parse()
|
||||
@@ -65,6 +73,8 @@ impl AppConfig {
|
||||
jwt_issuer,
|
||||
jwt_audience,
|
||||
jwt_expiry_minutes,
|
||||
download_token_audience,
|
||||
download_token_expiry_minutes,
|
||||
refresh_token_expiry_days,
|
||||
refresh_cookie_secure,
|
||||
refresh_cookie_domain,
|
||||
|
||||
@@ -9,5 +9,6 @@ pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod utils;
|
||||
pub mod workers;
|
||||
pub use workers::{default_handlers, Worker};
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -10,10 +10,13 @@ use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{document_tags, documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::AuthenticatedUser,
|
||||
error::{AppError, AppResult},
|
||||
};
|
||||
|
||||
use super::documents::{
|
||||
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso,
|
||||
@@ -162,6 +165,7 @@ pub async fn create_folder(
|
||||
pub async fn list_folder_contents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<FolderContentsResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
@@ -214,14 +218,18 @@ pub async fn list_folder_contents(
|
||||
|
||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
||||
|
||||
let documents = 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 documents = 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();
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
thumbnail,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(Json(FolderContentsResponse {
|
||||
folder,
|
||||
@@ -234,6 +242,7 @@ pub async fn search_documents(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_identifier): Path<String>,
|
||||
Query(params): Query<DocumentSearchQuery>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
@@ -392,14 +401,18 @@ pub async fn search_documents(
|
||||
drop(conn);
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/:id/tags", post(documents::assign_tags))
|
||||
.route("/:id/tags/:tag_id", delete(documents::remove_tag));
|
||||
|
||||
let download_routes =
|
||||
Router::new().route("/download/:token", get(documents::download_with_token));
|
||||
|
||||
let folders_routes = Router::new()
|
||||
.route("/", post(folders::create_folder))
|
||||
.route("/path", post(folders::ensure_folder_path))
|
||||
@@ -85,7 +88,9 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.route("/:id/contents", get(folders::list_folder_contents))
|
||||
.route("/:id/documents", get(folders::search_documents));
|
||||
|
||||
let tags_routes = Router::new().route("/", get(tags::list_tags).post(tags::create_tag));
|
||||
let tags_routes = Router::new()
|
||||
.route("/", get(tags::list_tags).post(tags::create_tag))
|
||||
.route("/:id", patch(tags::update_tag));
|
||||
|
||||
let protected_state = state.clone();
|
||||
let protected_routes = Router::new()
|
||||
@@ -96,6 +101,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||
|
||||
Router::new()
|
||||
.merge(download_routes)
|
||||
.nest("/api/auth", auth_routes)
|
||||
.merge(protected_routes)
|
||||
.with_state(state)
|
||||
|
||||
+144
-10
@@ -1,32 +1,69 @@
|
||||
use axum::{extract::State, Json};
|
||||
use diesel::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use crate::utils::json::{classify_nullable, NullableValue};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use diesel::{dsl::count_star, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{NewTag, Tag};
|
||||
use crate::schema::tags;
|
||||
use crate::schema::{document_tags, tags};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::documents::TagResponse;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTagRequest {
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagResponse>>> {
|
||||
#[derive(AsChangeset, Default)]
|
||||
#[diesel(table_name = tags)]
|
||||
struct UpdateTagChangeset<'a> {
|
||||
label: Option<&'a str>,
|
||||
color: Option<Option<&'a str>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TagCatalogEntry {
|
||||
pub id: Uuid,
|
||||
pub label: String,
|
||||
pub color: Option<String>,
|
||||
pub usage_count: i64,
|
||||
}
|
||||
|
||||
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
|
||||
let response = tag_list.into_iter().map(TagResponse::from).collect();
|
||||
|
||||
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
|
||||
.group_by(document_tags::tag_id)
|
||||
.select((document_tags::tag_id, count_star()))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
|
||||
|
||||
let response = tag_list
|
||||
.into_iter()
|
||||
.map(|tag| TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: *usage_map.get(&tag.id).unwrap_or(&0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub async fn create_tag(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagResponse>> {
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
@@ -53,5 +90,102 @@ pub async fn create_tag(
|
||||
}
|
||||
|
||||
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
|
||||
Ok(Json(TagResponse::from(tag)))
|
||||
Ok(Json(TagCatalogEntry {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: tag.color,
|
||||
usage_count: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_tag(
|
||||
State(state): State<AppState>,
|
||||
Path(tag_id): Path<Uuid>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
let mut conn = state.db()?;
|
||||
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
|
||||
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
|
||||
|
||||
if matches!(label_class, NullableValue::Omitted)
|
||||
&& matches!(color_class, NullableValue::Omitted)
|
||||
{
|
||||
return Err(AppError::bad_request("no changes supplied"));
|
||||
}
|
||||
|
||||
let mut new_label: Option<String> = None;
|
||||
let mut label_changed = false;
|
||||
match label_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
return Err(AppError::bad_request("label cannot be null"));
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
if trimmed != existing.label {
|
||||
let duplicate = tags::table
|
||||
.filter(tags::label.eq(trimmed))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
return Err(AppError::bad_request("tag label already exists"));
|
||||
}
|
||||
new_label = Some(trimmed.to_string());
|
||||
label_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut color_change: Option<Option<String>> = None;
|
||||
let mut color_changed = false;
|
||||
match color_class {
|
||||
NullableValue::Omitted => {}
|
||||
NullableValue::Null => {
|
||||
color_change = Some(None);
|
||||
color_changed = true;
|
||||
}
|
||||
NullableValue::String(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("color must not be empty"));
|
||||
}
|
||||
if existing.color.as_deref() != Some(trimmed) {
|
||||
color_change = Some(Some(trimmed.to_string()));
|
||||
color_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !label_changed && !color_changed {
|
||||
return Err(AppError::bad_request("no changes supplied"));
|
||||
}
|
||||
|
||||
let changeset = UpdateTagChangeset {
|
||||
label: new_label.as_deref(),
|
||||
color: color_change
|
||||
.as_ref()
|
||||
.map(|opt| opt.as_ref().map(|value| value.as_str())),
|
||||
};
|
||||
|
||||
diesel::update(tags::table.find(tag_id))
|
||||
.set(&changeset)
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
|
||||
let usage_count: i64 = document_tags::table
|
||||
.filter(document_tags::tag_id.eq(tag_id))
|
||||
.select(count_star())
|
||||
.first(&mut conn)?;
|
||||
|
||||
Ok(Json(TagCatalogEntry {
|
||||
id: updated.id,
|
||||
label: updated.label,
|
||||
color: updated.color,
|
||||
usage_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub trait ObjectStorage: Send + Sync + 'static {
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
|
||||
@@ -43,6 +44,7 @@ impl ObjectStorage for S3Storage {
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut request = self
|
||||
.client
|
||||
@@ -55,6 +57,10 @@ impl ObjectStorage for S3Storage {
|
||||
request = request.content_type(content_type);
|
||||
}
|
||||
|
||||
if let Some(content_disposition) = content_disposition {
|
||||
request = request.content_disposition(content_disposition);
|
||||
}
|
||||
|
||||
request
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub enum NullableValue {
|
||||
Omitted,
|
||||
Null,
|
||||
String(String),
|
||||
}
|
||||
|
||||
pub fn classify_nullable(optional_value: Option<&Value>) -> Result<NullableValue, String> {
|
||||
match optional_value {
|
||||
None => Ok(NullableValue::Omitted),
|
||||
Some(Value::Null) => Ok(NullableValue::Null),
|
||||
Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())),
|
||||
Some(other) => Err(format!("expected string or null, got {other}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod json;
|
||||
@@ -140,6 +140,7 @@ impl JobHandler for GenerateOcrTextJob {
|
||||
&s3_key,
|
||||
generation.text.into_bytes(),
|
||||
Some("text/plain".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -118,6 +118,7 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
&s3_key,
|
||||
generation.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ pub struct StoredObject {
|
||||
pub key: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub content_type: Option<String>,
|
||||
pub content_disposition: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -51,11 +52,13 @@ impl ObjectStorage for FakeStorage {
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
) -> Result<()> {
|
||||
let stored = StoredObject {
|
||||
key: key.to_string(),
|
||||
bytes,
|
||||
content_type,
|
||||
content_disposition,
|
||||
};
|
||||
let mut guard = self.objects.lock().await;
|
||||
guard.insert(stored.key.clone(), stored);
|
||||
@@ -119,6 +122,8 @@ impl TestApp {
|
||||
jwt_issuer: "test-issuer".to_string(),
|
||||
jwt_audience: "test-audience".to_string(),
|
||||
jwt_expiry_minutes: 60,
|
||||
download_token_audience: "test-download".to_string(),
|
||||
download_token_expiry_minutes: 60,
|
||||
refresh_token_expiry_days: 30,
|
||||
refresh_cookie_secure: false,
|
||||
refresh_cookie_domain: None,
|
||||
|
||||
@@ -22,6 +22,7 @@ struct DocumentInfo {
|
||||
deleted_at: Option<String>,
|
||||
issued_at: Option<String>,
|
||||
tags: Vec<TagSummary>,
|
||||
download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -42,6 +43,7 @@ struct DocumentAssetInfo {
|
||||
struct DocumentListItem {
|
||||
id: Uuid,
|
||||
current_version: i32,
|
||||
download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -154,6 +156,7 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert_eq!(detail.document.deleted_at, None);
|
||||
assert!(detail.document.issued_at.is_none());
|
||||
assert!(detail.document.tags.is_empty());
|
||||
assert!(detail.document.download_path.starts_with("/download/"));
|
||||
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
|
||||
assert!(detail.assets.is_empty());
|
||||
|
||||
@@ -173,6 +176,7 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
let item = list.pop().unwrap();
|
||||
assert_eq!(item.id, detail.document.id);
|
||||
assert_eq!(item.current_version, 1);
|
||||
assert!(item.download_path.starts_with("/download/"));
|
||||
|
||||
let download = app
|
||||
.get(
|
||||
@@ -186,6 +190,15 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert!(download_info.url.contains(&detail.current_version.s3_key));
|
||||
assert_eq!(download_info.filename, "doc.txt");
|
||||
|
||||
let redirect = app.get(&detail.document.download_path, None).await?;
|
||||
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
let location = redirect
|
||||
.headers()
|
||||
.get("location")
|
||||
.expect("redirect location header");
|
||||
let location = location.to_str().expect("location header utf8");
|
||||
assert!(location.contains(&detail.current_version.s3_key));
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,11 +21,16 @@ struct DocumentInfo {
|
||||
#[derive(Deserialize)]
|
||||
struct TagInfo {
|
||||
label: String,
|
||||
#[allow(dead_code)]
|
||||
color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TagResponse {
|
||||
id: Uuid,
|
||||
label: String,
|
||||
color: Option<String>,
|
||||
usage_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -75,6 +80,53 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
assert_eq!(create_tag.status(), StatusCode::OK);
|
||||
let body = body_to_vec(create_tag.into_body()).await?;
|
||||
let tag: TagResponse = serde_json::from_slice(&body)?;
|
||||
assert_eq!(tag.label, "Important");
|
||||
assert_eq!(tag.color.as_deref(), Some("#FF0000"));
|
||||
assert_eq!(tag.usage_count, 0);
|
||||
|
||||
let update = app
|
||||
.patch_json(
|
||||
&format!("/api/tags/{}", tag.id),
|
||||
&serde_json::json!({
|
||||
"label": "Critical",
|
||||
"color": "#00FF00"
|
||||
}),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
let updated_status = update.status();
|
||||
let updated_body = body_to_vec(update.into_body()).await?;
|
||||
if updated_status != StatusCode::OK {
|
||||
panic!(
|
||||
"update tag failed: {}",
|
||||
String::from_utf8_lossy(&updated_body)
|
||||
);
|
||||
}
|
||||
let updated: TagResponse = serde_json::from_slice(&updated_body)?;
|
||||
assert_eq!(updated.label, "Critical");
|
||||
assert_eq!(updated.color.as_deref(), Some("#00FF00"));
|
||||
assert_eq!(updated.usage_count, 0);
|
||||
|
||||
let clear_color = app
|
||||
.patch_json(
|
||||
&format!("/api/tags/{}", tag.id),
|
||||
&serde_json::json!({
|
||||
"color": null
|
||||
}),
|
||||
Some(&token),
|
||||
)
|
||||
.await?;
|
||||
let cleared_status = clear_color.status();
|
||||
let cleared_body = body_to_vec(clear_color.into_body()).await?;
|
||||
if cleared_status != StatusCode::OK {
|
||||
panic!(
|
||||
"clear color failed: {}",
|
||||
String::from_utf8_lossy(&cleared_body)
|
||||
);
|
||||
}
|
||||
let cleared: TagResponse = serde_json::from_slice(&cleared_body)?;
|
||||
assert_eq!(cleared.color, None);
|
||||
assert_eq!(cleared.usage_count, 0);
|
||||
|
||||
let assign = app
|
||||
.post_json(
|
||||
@@ -97,7 +149,7 @@ async fn tag_assignment_flow() -> Result<()> {
|
||||
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||
let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||
assert_eq!(refreshed_detail.document.tags.len(), 1);
|
||||
assert_eq!(refreshed_detail.document.tags[0].label, "Important");
|
||||
assert_eq!(refreshed_detail.document.tags[0].label, "Critical");
|
||||
|
||||
let remove = app
|
||||
.delete(
|
||||
|
||||
Reference in New Issue
Block a user