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
+37
View File
@@ -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,
}
+10
View File
@@ -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,
+1
View File
@@ -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
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 {
+30 -17
View File
@@ -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))
}
+7 -1
View File
@@ -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
View File
@@ -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,
}))
}
+6
View File
@@ -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
+16
View File
@@ -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}")),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod json;
+1
View File
@@ -140,6 +140,7 @@ impl JobHandler for GenerateOcrTextJob {
&s3_key,
generation.text.into_bytes(),
Some("text/plain".into()),
None,
)
.await
{
+1
View File
@@ -118,6 +118,7 @@ impl JobHandler for GenerateThumbnailsJob {
&s3_key,
generation.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{