cleanup
This commit is contained in:
+78
-160
@@ -118,13 +118,55 @@ impl PasskeyService {
|
||||
.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(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
let existing: Vec<UserPasskey> = passkey_dsl::user_passkeys
|
||||
.filter(passkey_dsl::user_id.eq(user.id))
|
||||
.filter(passkey_dsl::revoked_at.is_null())
|
||||
@@ -141,38 +183,7 @@ impl PasskeyService {
|
||||
)
|
||||
};
|
||||
|
||||
let (challenge, state) = self
|
||||
.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,
|
||||
})
|
||||
self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude)
|
||||
}
|
||||
|
||||
pub fn start_signup_registration(
|
||||
@@ -181,50 +192,16 @@ impl PasskeyService {
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
) -> AppResult<RegistrationChallengeResponse> {
|
||||
self.prune_expired(conn);
|
||||
|
||||
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,
|
||||
})
|
||||
self.begin_registration(conn, user_id, username, None, None)
|
||||
}
|
||||
|
||||
pub fn finish_registration(
|
||||
fn complete_registration(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
challenge_id: Uuid,
|
||||
credential: RegisterPublicKeyCredential,
|
||||
nickname: Option<String>,
|
||||
) -> AppResult<UserPasskey> {
|
||||
credential: &RegisterPublicKeyCredential,
|
||||
expected_user: Option<Uuid>,
|
||||
) -> AppResult<PreparedPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.find(challenge_id)
|
||||
.first(conn)
|
||||
@@ -240,8 +217,14 @@ impl PasskeyService {
|
||||
return Err(AppError::bad_request("challenge is not for registration"));
|
||||
}
|
||||
|
||||
if record.user_id != Some(user.id) {
|
||||
return Err(AppError::unauthorized());
|
||||
if let Some(expected) = expected_user {
|
||||
if record.user_id != Some(expected) {
|
||||
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() {
|
||||
@@ -255,7 +238,7 @@ impl PasskeyService {
|
||||
|
||||
let passkey = self
|
||||
.webauthn
|
||||
.finish_passkey_registration(&credential, &state)
|
||||
.finish_passkey_registration(credential, &state)
|
||||
.map_err(|err| {
|
||||
tracing::warn!(error = %err, "passkey registration validation failed");
|
||||
AppError::bad_request("invalid passkey attestation")
|
||||
@@ -296,24 +279,36 @@ impl PasskeyService {
|
||||
.context("failed to serialise passkey")
|
||||
.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(),
|
||||
user_id: user.id,
|
||||
credential_id: credential_id_vec,
|
||||
public_key: public_key_bytes,
|
||||
credential: credential_json,
|
||||
sign_count: credential_struct.counter as i64,
|
||||
transports,
|
||||
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)
|
||||
.values(&new_passkey)
|
||||
.execute(conn)?;
|
||||
|
||||
diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?;
|
||||
|
||||
let created: UserPasskey = passkey_dsl::user_passkeys
|
||||
.find(new_passkey.id)
|
||||
.select(UserPasskey::as_select())
|
||||
@@ -410,84 +405,7 @@ impl PasskeyService {
|
||||
challenge_id: Uuid,
|
||||
credential: &RegisterPublicKeyCredential,
|
||||
) -> AppResult<PreparedPasskey> {
|
||||
let record: WebauthnChallenge = challenge_dsl::webauthn_challenges
|
||||
.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,
|
||||
})
|
||||
self.complete_registration(conn, challenge_id, credential, None)
|
||||
}
|
||||
|
||||
pub fn revoke_passkey(
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
schema::{correspondents, document_correspondents},
|
||||
utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
time::to_iso,
|
||||
},
|
||||
};
|
||||
@@ -111,16 +112,15 @@ pub async fn create_correspondent(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCorrespondentRequest>,
|
||||
) -> AppResult<Json<CorrespondentSummary>> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
let name = normalize_name(&payload.name, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
|
||||
let metadata_value = normalize_metadata(payload.metadata);
|
||||
let new_id = Uuid::new_v4();
|
||||
let new_correspondent = NewCorrespondent {
|
||||
id: new_id,
|
||||
name: name.to_string(),
|
||||
name: name.clone(),
|
||||
metadata: metadata_value,
|
||||
tenant_id,
|
||||
};
|
||||
@@ -170,21 +170,22 @@ pub async fn update_correspondent(
|
||||
|
||||
let mut new_name: Option<String> = None;
|
||||
if let Some(ref candidate) = payload.name {
|
||||
let trimmed = candidate.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
if trimmed != existing.name {
|
||||
let duplicate = correspondents::table
|
||||
.filter(correspondents::name.eq(trimmed))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()?;
|
||||
if duplicate.is_some() {
|
||||
return Err(AppError::bad_request("correspondent name already exists"));
|
||||
}
|
||||
new_name = Some(trimmed.to_string());
|
||||
let normalized = normalize_name(candidate, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
if normalized != existing.name {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
correspondents::table
|
||||
.filter(correspondents::name.eq(&normalized))
|
||||
.filter(correspondents::id.ne(correspondent_id))
|
||||
.filter(correspondents::tenant_id.eq(tenant_id))
|
||||
.first::<Correspondent>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("correspondent name already exists"),
|
||||
)?;
|
||||
new_name = Some(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::{DocumentType, NewDocumentType},
|
||||
schema::document_types,
|
||||
utils::named_entity::normalize_name,
|
||||
};
|
||||
|
||||
use super::documents::DocumentTypeResponse;
|
||||
@@ -64,15 +65,14 @@ pub async fn create_document_type(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateDocumentTypeRequest>,
|
||||
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
let name = normalize_name(&payload.name, || {
|
||||
AppError::bad_request("name must not be empty")
|
||||
})?;
|
||||
|
||||
let new_type = NewDocumentType {
|
||||
id: Uuid::new_v4(),
|
||||
tenant_id,
|
||||
name: name.to_string(),
|
||||
name: name.clone(),
|
||||
};
|
||||
|
||||
match diesel::insert_into(document_types::table)
|
||||
@@ -111,19 +111,20 @@ pub async fn update_document_type(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateDocumentTypeRequest>,
|
||||
) -> AppResult<Json<DocumentTypeResponse>> {
|
||||
let name = payload
|
||||
let name_raw = payload
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_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
|
||||
.filter(document_types::tenant_id.eq(tenant_id))
|
||||
.find(document_type_id);
|
||||
|
||||
let update_result = diesel::update(target.clone())
|
||||
.set(document_types::name.eq(name))
|
||||
.set(document_types::name.eq(&name))
|
||||
.get_result::<DocumentType>(&mut conn);
|
||||
|
||||
match update_result {
|
||||
|
||||
+20
-19
@@ -12,6 +12,7 @@ use crate::schema::{document_tags, tags};
|
||||
use crate::utils::{
|
||||
db::{no_content, EnsureEntity, IntoJsonResponse},
|
||||
json::deserialize_patch_field,
|
||||
named_entity::{ensure_name_available, normalize_name},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
@@ -125,13 +126,13 @@ pub async fn create_tag(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> AppResult<Json<TagCatalogEntry>> {
|
||||
if payload.label.trim().is_empty() {
|
||||
return Err(AppError::bad_request("label must not be empty"));
|
||||
}
|
||||
let label = normalize_name(&payload.label, || {
|
||||
AppError::bad_request("label must not be empty")
|
||||
})?;
|
||||
|
||||
let new_tag = NewTag {
|
||||
id: Uuid::new_v4(),
|
||||
label: payload.label.trim().to_string(),
|
||||
label: label.clone(),
|
||||
color: payload.color,
|
||||
tenant_id,
|
||||
};
|
||||
@@ -211,21 +212,21 @@ pub async fn update_tag(
|
||||
return Err(AppError::bad_request("label cannot be null"));
|
||||
}
|
||||
Some(Some(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))
|
||||
.filter(tags::tenant_id.eq(tenant_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());
|
||||
let normalized =
|
||||
normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
|
||||
if normalized != existing.label {
|
||||
ensure_name_available(
|
||||
|| {
|
||||
tags::table
|
||||
.filter(tags::label.eq(&normalized))
|
||||
.filter(tags::id.ne(tag_id))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.first::<Tag>(&mut conn)
|
||||
.optional()
|
||||
},
|
||||
|| AppError::bad_request("tag label already exists"),
|
||||
)?;
|
||||
new_label = Some(normalized);
|
||||
label_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod named_entity;
|
||||
pub mod storage_paths;
|
||||
pub mod time;
|
||||
pub mod tracing;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub mod analyze;
|
||||
pub mod common;
|
||||
pub mod index;
|
||||
pub mod ocr;
|
||||
pub mod tenants;
|
||||
|
||||
+27
-44
@@ -24,13 +24,16 @@ use crate::{
|
||||
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||
NewDocumentAssetObject,
|
||||
},
|
||||
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||
schema::{document_asset_objects, document_assets, documents},
|
||||
state::AppState,
|
||||
storage::TenantStorage,
|
||||
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";
|
||||
const MIN_TEXT_LENGTH: usize = 50;
|
||||
@@ -234,54 +237,34 @@ struct OcrGeneration {
|
||||
}
|
||||
|
||||
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 version: DocumentVersion = document_versions::table
|
||||
.find(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 base = load_document_version(
|
||||
state.as_ref(),
|
||||
payload.document_id,
|
||||
payload.document_version_id,
|
||||
)?;
|
||||
|
||||
let mut conn = state
|
||||
.db_for_tenant(tenant_id)
|
||||
.db_for_tenant(base.tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing_asset: Option<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||
.filter(document_assets::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let mut assets = load_version_assets(
|
||||
&mut conn,
|
||||
base.tenant_id,
|
||||
base.version.id,
|
||||
&[OCR_TEXT_ASSET_TYPE],
|
||||
)?;
|
||||
|
||||
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||
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:?}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (existing_asset, existing_objects) = assets
|
||||
.remove(OCR_TEXT_ASSET_TYPE)
|
||||
.map(|entry| (Some(entry.asset), entry.objects))
|
||||
.unwrap_or((None, Vec::new()));
|
||||
|
||||
let is_pdf = document_is_pdf(&document);
|
||||
let is_pdf = document_is_pdf(&base.document);
|
||||
if !is_pdf {
|
||||
return Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing_asset,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
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;
|
||||
|
||||
Ok(OcrContext {
|
||||
document,
|
||||
version,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_asset,
|
||||
existing_objects,
|
||||
skip,
|
||||
|
||||
@@ -25,8 +25,9 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
analyze::determine_thumbnail_support, fetch_version_object, handle_fetch_error, JobExecution,
|
||||
JobHandler,
|
||||
analyze::determine_thumbnail_support,
|
||||
common::{load_document_version, load_version_assets},
|
||||
fetch_version_object, handle_fetch_error, JobExecution, JobHandler,
|
||||
};
|
||||
|
||||
const THUMBNAIL_WIDTH: u32 = 512;
|
||||
@@ -406,68 +407,39 @@ fn load_thumbnail_context(
|
||||
state: Arc<AppState>,
|
||||
payload: &ThumbnailPayload,
|
||||
) -> 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
|
||||
.find(payload.document_version_id)
|
||||
.first(&mut conn)
|
||||
let mut conn = state
|
||||
.db_for_tenant(base.tenant_id)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if version.document_id != payload.document_id {
|
||||
return Err("document/version mismatch".into());
|
||||
}
|
||||
let mut assets = load_version_assets(
|
||||
&mut conn,
|
||||
base.tenant_id,
|
||||
base.version.id,
|
||||
&[THUMBNAIL_ASSET_TYPE, PREVIEW_ASSET_TYPE],
|
||||
)?;
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(payload.document_id)
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
let (existing_thumbnail, existing_thumbnail_objects) = assets
|
||||
.remove(THUMBNAIL_ASSET_TYPE)
|
||||
.map(|entry| (Some(entry.asset), entry.objects))
|
||||
.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
|
||||
.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);
|
||||
let (supported, _) = determine_thumbnail_support(&base.document);
|
||||
if !supported {
|
||||
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
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.cardinality)
|
||||
@@ -488,8 +460,8 @@ fn load_thumbnail_context(
|
||||
&& !needs_regeneration;
|
||||
|
||||
Ok(ThumbnailContext {
|
||||
document,
|
||||
version,
|
||||
document: base.document,
|
||||
version: base.version,
|
||||
existing_thumbnail,
|
||||
existing_thumbnail_objects,
|
||||
existing_preview,
|
||||
|
||||
@@ -3,7 +3,7 @@ mod common;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::http::{header::SET_COOKIE, StatusCode};
|
||||
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 papercrate::auth::passkeys::{
|
||||
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
|
||||
@@ -28,11 +28,6 @@ struct AuthenticatedUser {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginTenant {
|
||||
id: Uuid,
|
||||
@@ -100,7 +95,7 @@ async fn login_rejects_unknown_user() -> Result<()> {
|
||||
let response = app.post_json("/api/auth/login", &payload, None).await?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
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?;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
app.cleanup().await?;
|
||||
|
||||
@@ -29,7 +29,7 @@ use papercrate::state::AppState;
|
||||
use papercrate::storage::ObjectStorage;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{self, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
@@ -122,6 +122,13 @@ pub struct TestApp {
|
||||
storage: Arc<FakeStorage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiErrorResponse {
|
||||
pub error: String,
|
||||
#[serde(default)]
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn new() -> Result<Self> {
|
||||
let database_url = env::var("TEST_DATABASE_URL")
|
||||
@@ -538,6 +545,7 @@ impl TestApp {
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
document_type_id: None,
|
||||
};
|
||||
self.upload_document_with_extras(
|
||||
path,
|
||||
@@ -666,6 +674,7 @@ pub struct UploadExtras<'a> {
|
||||
pub correspondents_json: Option<&'a str>,
|
||||
pub issued_at: Option<&'a str>,
|
||||
pub skip_existing: bool,
|
||||
pub document_type_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl<'a> UploadExtras<'a> {
|
||||
@@ -677,6 +686,7 @@ impl<'a> UploadExtras<'a> {
|
||||
correspondents_json: None,
|
||||
issued_at: None,
|
||||
skip_existing: false,
|
||||
document_type_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +719,11 @@ mod helper_tests {
|
||||
let (access, refresh, refresh_id) = app.create_session(username).await?;
|
||||
assert!(!access.is_empty(), "access 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?;
|
||||
assert!(!bearer.is_empty(), "login_token must yield bearer");
|
||||
|
||||
@@ -2,7 +2,7 @@ mod common;
|
||||
|
||||
use anyhow::Result;
|
||||
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_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
@@ -97,13 +97,6 @@ struct AnalyzeJobPayload {
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FolderResponse {
|
||||
folder: FolderInfo,
|
||||
@@ -1062,7 +1055,7 @@ async fn patch_document_updates_title_and_handles_conflict() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(conflict.status(), StatusCode::CONFLICT);
|
||||
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"));
|
||||
|
||||
app.cleanup().await?;
|
||||
@@ -1244,7 +1237,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
let empty_issued = app
|
||||
@@ -1256,7 +1249,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST);
|
||||
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");
|
||||
|
||||
let invalid_merge = app
|
||||
@@ -1272,7 +1265,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST);
|
||||
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!(
|
||||
merge_error.error,
|
||||
"metadata value must be a JSON object when replace is false"
|
||||
@@ -1308,7 +1301,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
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_error: ErrorResponse = serde_json::from_slice(&merge_after_body)?;
|
||||
let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?;
|
||||
assert_eq!(
|
||||
merge_after_error.error,
|
||||
"existing metadata is not an object; set replace=true to overwrite"
|
||||
@@ -1323,7 +1316,7 @@ async fn patch_document_validation_errors() -> Result<()> {
|
||||
.await?;
|
||||
assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST);
|
||||
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!(
|
||||
malformed_error
|
||||
.error
|
||||
@@ -1591,7 +1584,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
|
||||
assert!(refreshed_a.status().is_success());
|
||||
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
|
||||
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
|
||||
.get(
|
||||
@@ -1602,7 +1598,10 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
|
||||
assert!(refreshed_b.status().is_success());
|
||||
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
|
||||
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?;
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user