This commit is contained in:
2025-10-31 01:52:51 +01:00
parent 722c2f9a5c
commit 17db827e3a
11 changed files with 220 additions and 334 deletions
+77 -159
View File
@@ -118,13 +118,55 @@ impl PasskeyService {
.execute(conn); .execute(conn);
} }
fn begin_registration(
&self,
conn: &mut PgConnection,
user_id: Uuid,
username: &str,
challenge_user_id: Option<Uuid>,
exclude: Option<Vec<CredentialID>>,
) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, exclude)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: challenge_user_id,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
}
pub fn start_registration( pub fn start_registration(
&self, &self,
conn: &mut PgConnection, conn: &mut PgConnection,
user: &User, user: &User,
) -> AppResult<RegistrationChallengeResponse> { ) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn);
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
.filter(passkey_dsl::user_id.eq(user.id)) .filter(passkey_dsl::user_id.eq(user.id))
.filter(passkey_dsl::revoked_at.is_null()) .filter(passkey_dsl::revoked_at.is_null())
@@ -141,38 +183,7 @@ impl PasskeyService {
) )
}; };
let (challenge, state) = self self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
.webauthn
.start_passkey_registration(user.id, &user.username, &user.username, exclude)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: Some(user.id),
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
} }
pub fn start_signup_registration( pub fn start_signup_registration(
@@ -181,50 +192,16 @@ impl PasskeyService {
user_id: Uuid, user_id: Uuid,
username: &str, username: &str,
) -> AppResult<RegistrationChallengeResponse> { ) -> AppResult<RegistrationChallengeResponse> {
self.prune_expired(conn); self.begin_registration(conn, user_id, username, None, None)
let (challenge, state) = self
.webauthn
.start_passkey_registration(user_id, username, username, None)
.map_err(|err| {
tracing::error!(error = %err, "failed to start passkey registration");
AppError::internal("failed to start passkey registration")
})?;
let challenge_id = Uuid::new_v4();
let expires_at = (Utc::now() + self.challenge_ttl).naive_utc();
let challenge_bytes: Vec<u8> = challenge.public_key.challenge.clone().into();
let state_bytes = serde_json::to_vec(&state)
.context("failed to encode passkey registration state")
.map_err(AppError::internal)?;
let record = NewWebauthnChallenge {
id: challenge_id,
user_id: None,
purpose: PURPOSE_REGISTRATION.to_string(),
challenge: challenge_bytes,
state: state_bytes,
expires_at,
};
diesel::insert_into(challenge_dsl::webauthn_challenges)
.values(&record)
.execute(conn)?;
Ok(RegistrationChallengeResponse {
challenge_id,
challenge,
})
} }
pub fn finish_registration( fn complete_registration(
&self, &self,
conn: &mut PgConnection, conn: &mut PgConnection,
user: &User,
challenge_id: Uuid, challenge_id: Uuid,
credential: RegisterPublicKeyCredential, credential: &RegisterPublicKeyCredential,
nickname: Option<String>, expected_user: Option<Uuid>,
) -> AppResult<UserPasskey> { ) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
.find(challenge_id) .find(challenge_id)
.first(conn) .first(conn)
@@ -240,9 +217,15 @@ impl PasskeyService {
return Err(AppError::bad_request("challenge is not for registration")); return Err(AppError::bad_request("challenge is not for registration"));
} }
if record.user_id != Some(user.id) { if let Some(expected) = expected_user {
if record.user_id != Some(expected) {
return Err(AppError::unauthorized()); return Err(AppError::unauthorized());
} }
} else if record.user_id.is_some() {
return Err(AppError::bad_request(
"unexpected user context for signup registration",
));
}
if record.expires_at < Utc::now().naive_utc() { if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
@@ -255,7 +238,7 @@ impl PasskeyService {
let passkey = self let passkey = self
.webauthn .webauthn
.finish_passkey_registration(&credential, &state) .finish_passkey_registration(credential, &state)
.map_err(|err| { .map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed"); tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation") AppError::bad_request("invalid passkey attestation")
@@ -296,24 +279,36 @@ impl PasskeyService {
.context("failed to serialise passkey") .context("failed to serialise passkey")
.map_err(AppError::internal)?; .map_err(AppError::internal)?;
let new_passkey = NewUserPasskey { diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id,
credential_id: credential_id_vec, credential_id: credential_id_vec,
public_key: public_key_bytes, public_key: public_key_bytes,
credential: credential_json, credential: credential_json,
sign_count: credential_struct.counter as i64, sign_count: credential_struct.counter as i64,
transports, transports,
aaguid, aaguid,
nickname, })
}; }
pub fn finish_registration(
&self,
conn: &mut PgConnection,
user: &User,
challenge_id: Uuid,
credential: RegisterPublicKeyCredential,
nickname: Option<String>,
) -> AppResult<UserPasskey> {
let prepared =
self.complete_registration(conn, challenge_id, &credential, Some(user.id))?;
let new_passkey = prepared.into_new_user_passkey(user.id, nickname);
diesel::insert_into(passkey_dsl::user_passkeys) diesel::insert_into(passkey_dsl::user_passkeys)
.values(&new_passkey) .values(&new_passkey)
.execute(conn)?; .execute(conn)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
let created: UserPasskey = passkey_dsl::user_passkeys let created: UserPasskey = passkey_dsl::user_passkeys
.find(new_passkey.id) .find(new_passkey.id)
.select(UserPasskey::as_select()) .select(UserPasskey::as_select())
@@ -410,84 +405,7 @@ impl PasskeyService {
challenge_id: Uuid, challenge_id: Uuid,
credential: &RegisterPublicKeyCredential, credential: &RegisterPublicKeyCredential,
) -> AppResult<PreparedPasskey> { ) -> AppResult<PreparedPasskey> {
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges self.complete_registration(conn, challenge_id, credential, None)
.find(challenge_id)
.first(conn)
.map_err(|err| {
if matches!(err, diesel::result::Error::NotFound) {
AppError::bad_request("challenge not found")
} else {
AppError::from(err)
}
})?;
if record.purpose != PURPOSE_REGISTRATION {
return Err(AppError::bad_request("challenge is not for registration"));
}
if record.expires_at < Utc::now().naive_utc() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::bad_request("challenge expired"));
}
let state: PasskeyRegistration = serde_json::from_slice(&record.state)
.context("failed to decode registration state")
.map_err(AppError::internal)?;
let passkey = self
.webauthn
.finish_passkey_registration(credential, &state)
.map_err(|err| {
tracing::warn!(error = %err, "passkey registration validation failed");
AppError::bad_request("invalid passkey attestation")
})?;
let credential_struct: Credential = passkey.clone().into();
let credential_id_vec: Vec<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(conn)
.optional()?;
if duplicate.is_some() {
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
return Err(AppError::conflict("credential already registered"));
}
let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred)
.context("failed to encode credential public key")
.map_err(AppError::internal)?;
let transports: Vec<Option<String>> = credential_struct
.transports
.clone()
.unwrap_or_default()
.into_iter()
.map(|transport| Some(transport.as_ref().to_string()))
.collect();
let aaguid = match credential_struct.attestation.metadata {
AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
Some(aaguid)
}
_ => None,
};
let credential_json = serde_json::to_value(&passkey)
.context("failed to serialise passkey")
.map_err(AppError::internal)?;
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
Ok(PreparedPasskey {
id: Uuid::new_v4(),
credential_id: credential_id_vec,
public_key: public_key_bytes,
credential: credential_json,
sign_count: credential_struct.counter as i64,
transports,
aaguid,
})
} }
pub fn revoke_passkey( pub fn revoke_passkey(
+18 -17
View File
@@ -15,6 +15,7 @@ use crate::{
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ utils::{
db::{no_content, EnsureEntity, IntoJsonResponse}, db::{no_content, EnsureEntity, IntoJsonResponse},
named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
}; };
@@ -111,16 +112,15 @@ pub async fn create_correspondent(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim(); let name = normalize_name(&payload.name, || {
if name.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
}
let metadata_value = normalize_metadata(payload.metadata); let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4(); let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent { let new_correspondent = NewCorrespondent {
id: new_id, id: new_id,
name: name.to_string(), name: name.clone(),
metadata: metadata_value, metadata: metadata_value,
tenant_id, tenant_id,
}; };
@@ -170,21 +170,22 @@ pub async fn update_correspondent(
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
let trimmed = candidate.trim(); let normalized = normalize_name(candidate, || {
if trimmed.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
} if normalized != existing.name {
if trimmed != existing.name { ensure_name_available(
let duplicate = correspondents::table || {
.filter(correspondents::name.eq(trimmed)) correspondents::table
.filter(correspondents::name.eq(&normalized))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("correspondent name already exists")); || AppError::bad_request("correspondent name already exists"),
} )?;
new_name = Some(trimmed.to_string()); new_name = Some(normalized);
} }
} }
+10 -9
View File
@@ -12,6 +12,7 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{DocumentType, NewDocumentType}, models::{DocumentType, NewDocumentType},
schema::document_types, schema::document_types,
utils::named_entity::normalize_name,
}; };
use super::documents::DocumentTypeResponse; use super::documents::DocumentTypeResponse;
@@ -64,15 +65,14 @@ pub async fn create_document_type(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateDocumentTypeRequest>, Json(payload): Json<CreateDocumentTypeRequest>,
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> { ) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
let name = payload.name.trim(); let name = normalize_name(&payload.name, || {
if name.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
}
let new_type = NewDocumentType { let new_type = NewDocumentType {
id: Uuid::new_v4(), id: Uuid::new_v4(),
tenant_id, tenant_id,
name: name.to_string(), name: name.clone(),
}; };
match diesel::insert_into(document_types::table) match diesel::insert_into(document_types::table)
@@ -111,19 +111,20 @@ pub async fn update_document_type(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateDocumentTypeRequest>, Json(payload): Json<UpdateDocumentTypeRequest>,
) -> AppResult<Json<DocumentTypeResponse>> { ) -> AppResult<Json<DocumentTypeResponse>> {
let name = payload let name_raw = payload
.name .name
.as_ref() .as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::bad_request("name must be provided and not empty"))?; .ok_or_else(|| AppError::bad_request("name must be provided and not empty"))?;
let name = normalize_name(name_raw, || {
AppError::bad_request("name must be provided and not empty")
})?;
let target = document_types::table let target = document_types::table
.filter(document_types::tenant_id.eq(tenant_id)) .filter(document_types::tenant_id.eq(tenant_id))
.find(document_type_id); .find(document_type_id);
let update_result = diesel::update(target.clone()) let update_result = diesel::update(target.clone())
.set(document_types::name.eq(name)) .set(document_types::name.eq(&name))
.get_result::<DocumentType>(&mut conn); .get_result::<DocumentType>(&mut conn);
match update_result { match update_result {
+17 -16
View File
@@ -12,6 +12,7 @@ use crate::schema::{document_tags, tags};
use crate::utils::{ use crate::utils::{
db::{no_content, EnsureEntity, IntoJsonResponse}, db::{no_content, EnsureEntity, IntoJsonResponse},
json::deserialize_patch_field, json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
}; };
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
@@ -125,13 +126,13 @@ pub async fn create_tag(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() { let label = normalize_name(&payload.label, || {
return Err(AppError::bad_request("label must not be empty")); AppError::bad_request("label must not be empty")
} })?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: label.clone(),
color: payload.color, color: payload.color,
tenant_id, tenant_id,
}; };
@@ -211,21 +212,21 @@ pub async fn update_tag(
return Err(AppError::bad_request("label cannot be null")); return Err(AppError::bad_request("label cannot be null"));
} }
Some(Some(value)) => { Some(Some(value)) => {
let trimmed = value.trim(); let normalized =
if trimmed.is_empty() { normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
return Err(AppError::bad_request("label must not be empty")); if normalized != existing.label {
} ensure_name_available(
if trimmed != existing.label { || {
let duplicate = tags::table tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(&normalized))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("tag label already exists")); || AppError::bad_request("tag label already exists"),
} )?;
new_label = Some(trimmed.to_string()); new_label = Some(normalized);
label_changed = true; label_changed = true;
} }
} }
+1
View File
@@ -3,6 +3,7 @@ pub mod db;
pub mod error; pub mod error;
pub mod http; pub mod http;
pub mod json; pub mod json;
pub mod named_entity;
pub mod storage_paths; pub mod storage_paths;
pub mod time; pub mod time;
pub mod tracing; pub mod tracing;
+1
View File
@@ -13,6 +13,7 @@ use crate::{
}; };
pub mod analyze; pub mod analyze;
pub mod common;
pub mod index; pub mod index;
pub mod ocr; pub mod ocr;
pub mod tenants; pub mod tenants;
+27 -44
View File
@@ -24,13 +24,16 @@ use crate::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject, NewDocumentAssetObject,
}, },
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_asset_objects, document_assets, documents},
state::AppState, state::AppState,
storage::TenantStorage, storage::TenantStorage,
utils::storage_paths::document_asset_object_prefix, utils::storage_paths::document_asset_object_prefix,
}; };
use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler}; use super::{
common::{load_document_version, load_version_assets},
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50; const MIN_TEXT_LENGTH: usize = 50;
@@ -234,54 +237,34 @@ struct OcrGeneration {
} }
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> { fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
let mut base_conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let base = load_document_version(
state.as_ref(),
let version: DocumentVersion = document_versions::table payload.document_id,
.find(payload.document_version_id) payload.document_version_id,
.first(&mut base_conn) )?;
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut base_conn)
.map_err(|err| format!("{err:?}"))?;
let tenant_id = document.tenant_id;
drop(base_conn);
let mut conn = state let mut conn = state
.db_for_tenant(tenant_id) .db_for_tenant(base.tenant_id)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table let mut assets = load_version_assets(
.filter(document_assets::document_version_id.eq(payload.document_version_id)) &mut conn,
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) base.tenant_id,
.filter(document_assets::tenant_id.eq(tenant_id)) base.version.id,
.first(&mut conn) &[OCR_TEXT_ASSET_TYPE],
.optional() )?;
.map_err(|err| format!("{err:?}"))?;
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset { let (existing_asset, existing_objects) = assets
document_asset_objects::table .remove(OCR_TEXT_ASSET_TYPE)
.filter(document_asset_objects::asset_id.eq(asset.id)) .map(|entry| (Some(entry.asset), entry.objects))
.filter(document_asset_objects::tenant_id.eq(tenant_id)) .unwrap_or((None, Vec::new()));
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?
} else {
Vec::new()
};
let is_pdf = document_is_pdf(&document); let is_pdf = document_is_pdf(&base.document);
if !is_pdf { if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document, document: base.document,
version, version: base.version,
existing_asset: existing_asset, existing_asset,
existing_objects, existing_objects,
skip: true, skip: true,
}); });
@@ -290,8 +273,8 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
let skip = existing_asset.is_some() && !payload.force; let skip = existing_asset.is_some() && !payload.force;
Ok(OcrContext { Ok(OcrContext {
document, document: base.document,
version, version: base.version,
existing_asset, existing_asset,
existing_objects, existing_objects,
skip, skip,
+28 -56
View File
@@ -25,8 +25,9 @@ use crate::{
}; };
use super::{ use super::{
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution, analyze::determine_thumbnail_support,
JobHandler, common::{load_document_version, load_version_assets},
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
}; };
const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_WIDTH: u32 = 512;
@@ -406,68 +407,39 @@ fn load_thumbnail_context(
state: Arc<AppState>, state: Arc<AppState>,
payload: &ThumbnailPayload, payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> { ) -> Result<ThumbnailContext, String> {
let mut conn = state.db_unscoped().map_err(|err| format!("{err:?}"))?; let base = load_document_version(
state.as_ref(),
payload.document_id,
payload.document_version_id,
)?;
let version: DocumentVersion = document_versions::table let mut conn = state
.find(payload.document_version_id) .db_for_tenant(base.tenant_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id { let mut assets = load_version_assets(
return Err("document/version mismatch".into()); &mut conn,
} base.tenant_id,
base.version.id,
&[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE],
)?;
let document: Document = documents::table let (existing_thumbnail, existing_thumbnail_objects) = assets
.find(payload.document_id) .remove(THUMBNAIL_ASSET_TYPE)
.first(&mut conn) .map(|entry| (Some(entry.asset), entry.objects))
.map_err(|err| format!("{err:?}"))?; .unwrap_or((None, Vec::new()));
let tenant_id = document.tenant_id; let (existing_preview, existing_preview_objects) = assets
.remove(PREVIEW_ASSET_TYPE)
.map(|entry| (Some(entry.asset), entry.objects))
.unwrap_or((None, Vec::new()));
let existing_assets: Vec<DocumentAsset> = document_assets::table let (supported, _) = determine_thumbnail_support(&base.document);
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(),
]))
.filter(document_assets::tenant_id.eq(tenant_id))
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets {
match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => {
existing_thumbnail_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_thumbnail = Some(asset);
}
PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.filter(document_asset_objects::tenant_id.eq(tenant_id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_preview = Some(asset);
}
_ => {}
}
}
let (supported, _) = determine_thumbnail_support(&document);
if !supported { if !supported {
return Err("thumbnail generation not supported for this document".into()); return Err("thumbnail generation not supported for this document".into());
} }
let expected_cardinality = expected_asset_cardinality(&document, &version); let expected_cardinality = expected_asset_cardinality(&base.document, &base.version);
let preview_cardinality = existing_preview let preview_cardinality = existing_preview
.as_ref() .as_ref()
.and_then(|asset| asset.cardinality) .and_then(|asset| asset.cardinality)
@@ -488,8 +460,8 @@ fn load_thumbnail_context(
&& !needs_regeneration; && !needs_regeneration;
Ok(ThumbnailContext { Ok(ThumbnailContext {
document, document: base.document,
version, version: base.version,
existing_thumbnail, existing_thumbnail,
existing_thumbnail_objects, existing_thumbnail_objects,
existing_preview, existing_preview,
+3 -8
View File
@@ -3,7 +3,7 @@ mod common;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use axum::http::{header::SET_COOKIE, StatusCode}; use axum::http::{header::SET_COOKIE, StatusCode};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use common::{acquire_db_lock, body_to_vec, TestApp}; use common::{acquire_db_lock, body_to_vec, ApiErrorResponse, TestApp};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::auth::passkeys::{ use papercrate::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
@@ -28,11 +28,6 @@ struct AuthenticatedUser {
username: String, username: String,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct LoginTenant { struct LoginTenant {
id: Uuid, id: Uuid,
@@ -100,7 +95,7 @@ async fn login_rejects_unknown_user() -> Result<()> {
let response = app.post_json("/api/auth/login", &payload, None).await?; let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported"); assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?; app.cleanup().await?;
@@ -411,7 +406,7 @@ async fn login_rejects_invalid_password() -> Result<()> {
let response = app.post_json("/api/auth/login", &payload, None).await?; let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?; let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported"); assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?; app.cleanup().await?;
+16 -2
View File
@@ -29,7 +29,7 @@ use papercrate::state::AppState;
use papercrate::storage::ObjectStorage; use papercrate::storage::ObjectStorage;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore; use rand::RngCore;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::{self, json}; use serde_json::{self, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -122,6 +122,13 @@ pub struct TestApp {
storage: Arc<FakeStorage>, storage: Arc<FakeStorage>,
} }
#[derive(Debug, Deserialize)]
pub struct ApiErrorResponse {
pub error: String,
#[serde(default)]
pub code: Option<String>,
}
impl TestApp { impl TestApp {
pub async fn new() -> Result<Self> { pub async fn new() -> Result<Self> {
let database_url = env::var("TEST_DATABASE_URL") let database_url = env::var("TEST_DATABASE_URL")
@@ -538,6 +545,7 @@ impl TestApp {
correspondents_json: None, correspondents_json: None,
issued_at: None, issued_at: None,
skip_existing: false, skip_existing: false,
document_type_id: None,
}; };
self.upload_document_with_extras( self.upload_document_with_extras(
path, path,
@@ -666,6 +674,7 @@ pub struct UploadExtras<'a> {
pub correspondents_json: Option<&'a str>, pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>, pub issued_at: Option<&'a str>,
pub skip_existing: bool, pub skip_existing: bool,
pub document_type_id: Option<Uuid>,
} }
impl<'a> UploadExtras<'a> { impl<'a> UploadExtras<'a> {
@@ -677,6 +686,7 @@ impl<'a> UploadExtras<'a> {
correspondents_json: None, correspondents_json: None,
issued_at: None, issued_at: None,
skip_existing: false, skip_existing: false,
document_type_id: None,
} }
} }
} }
@@ -709,7 +719,11 @@ mod helper_tests {
let (access, refresh, refresh_id) = app.create_session(username).await?; let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty"); assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty"); assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(refresh_id, Uuid::nil(), "refresh token id should be assigned"); assert_ne!(
refresh_id,
Uuid::nil(),
"refresh token id should be assigned"
);
let bearer = app.login_token(username, password).await?; let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer"); assert!(!bearer.is_empty(), "login_token must yield bearer");
+15 -16
View File
@@ -2,7 +2,7 @@ mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras}; use common::{acquire_db_lock, body_to_vec, ApiErrorResponse, TestApp, UploadExtras};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
@@ -97,13 +97,6 @@ struct AnalyzeJobPayload {
force: bool, force: bool,
} }
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct FolderResponse { struct FolderResponse {
folder: FolderInfo, folder: FolderInfo,
@@ -1062,7 +1055,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
.await?; .await?;
assert_eq!(conflict.status(), StatusCode::CONFLICT); assert_eq!(conflict.status(), StatusCode::CONFLICT);
let conflict_body = body_to_vec(conflict.into_body()).await?; let conflict_body = body_to_vec(conflict.into_body()).await?;
let conflict_json: ErrorResponse = serde_json::from_slice(&conflict_body)?; let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?;
assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename")); assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename"));
app.cleanup().await?; app.cleanup().await?;
@@ -1244,7 +1237,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST); assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
let title_body = body_to_vec(empty_title.into_body()).await?; let title_body = body_to_vec(empty_title.into_body()).await?;
let title_error: ErrorResponse = serde_json::from_slice(&title_body)?; let title_error: ApiErrorResponse = serde_json::from_slice(&title_body)?;
assert_eq!(title_error.error, "title must not be empty"); assert_eq!(title_error.error, "title must not be empty");
let empty_issued = app let empty_issued = app
@@ -1256,7 +1249,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST); assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
let issued_body = body_to_vec(empty_issued.into_body()).await?; let issued_body = body_to_vec(empty_issued.into_body()).await?;
let issued_error: ErrorResponse = serde_json::from_slice(&issued_body)?; let issued_error: ApiErrorResponse = serde_json::from_slice(&issued_body)?;
assert_eq!(issued_error.error, "issued_at must not be empty"); assert_eq!(issued_error.error, "issued_at must not be empty");
let invalid_merge = app let invalid_merge = app
@@ -1272,7 +1265,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
let merge_body = body_to_vec(invalid_merge.into_body()).await?; let merge_body = body_to_vec(invalid_merge.into_body()).await?;
let merge_error: ErrorResponse = serde_json::from_slice(&merge_body)?; let merge_error: ApiErrorResponse = serde_json::from_slice(&merge_body)?;
assert_eq!( assert_eq!(
merge_error.error, merge_error.error,
"metadata value must be a JSON object when replace is false" "metadata value must be a JSON object when replace is false"
@@ -1308,7 +1301,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST); assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST);
let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?; let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?;
let merge_after_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?; let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?;
assert_eq!( assert_eq!(
merge_after_error.error, merge_after_error.error,
"existing metadata is not an object; set replace=true to overwrite" "existing metadata is not an object; set replace=true to overwrite"
@@ -1323,7 +1316,7 @@ async fn patch_document_validation_errors() -> Result<()> {
.await?; .await?;
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST); assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?; let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?;
let malformed_error: ErrorResponse = serde_json::from_slice(&malformed_body)?; let malformed_error: ApiErrorResponse = serde_json::from_slice(&malformed_body)?;
assert!( assert!(
malformed_error malformed_error
.error .error
@@ -1591,7 +1584,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
assert!(refreshed_a.status().is_success()); assert!(refreshed_a.status().is_success());
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?; let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?; let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?;
assert_eq!(refreshed_a_detail.document.document_type_id, Some(invoices.id)); assert_eq!(
refreshed_a_detail.document.document_type_id,
Some(invoices.id)
);
let refreshed_b = app let refreshed_b = app
.get( .get(
@@ -1602,7 +1598,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
assert!(refreshed_b.status().is_success()); assert!(refreshed_b.status().is_success());
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?; let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?; let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?;
assert_eq!(refreshed_b_detail.document.document_type_id, Some(receipts.id)); assert_eq!(
refreshed_b_detail.document.document_type_id,
Some(receipts.id)
);
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())