diff --git a/CHANGELOG.md b/CHANGELOG.md index 949bb5af..a986cb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + - [744](https://github.com/thoth-pub/thoth/pull/744) - Support accessibility report file upload ## [[1.2.0]](https://github.com/thoth-pub/thoth/releases/tag/v1.2.0) - 2026-05-04 ### Added diff --git a/thoth-api/migrations/20260504_v1.2.1/down.sql b/thoth-api/migrations/20260504_v1.2.1/down.sql new file mode 100644 index 00000000..e0ac49d1 --- /dev/null +++ b/thoth-api/migrations/20260504_v1.2.1/down.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/thoth-api/migrations/20260504_v1.2.1/up.sql b/thoth-api/migrations/20260504_v1.2.1/up.sql new file mode 100644 index 00000000..298b8dfc --- /dev/null +++ b/thoth-api/migrations/20260504_v1.2.1/up.sql @@ -0,0 +1 @@ +ALTER TYPE file_type ADD VALUE IF NOT EXISTS 'accessibility_report'; diff --git a/thoth-api/migrations/20260505_v1.2.1/down.sql b/thoth-api/migrations/20260505_v1.2.1/down.sql new file mode 100644 index 00000000..fb2e7425 --- /dev/null +++ b/thoth-api/migrations/20260505_v1.2.1/down.sql @@ -0,0 +1,26 @@ +DROP INDEX IF EXISTS file_accessibility_report_publication_unique_idx; +DROP INDEX IF EXISTS file_upload_accessibility_report_publication_idx; + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_type_check; +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_type_check; + +DELETE FROM file WHERE file_type = 'accessibility_report'; +DELETE FROM file_upload WHERE file_type = 'accessibility_report'; + +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'additional_resource' AND additional_resource_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'work_featured_video' AND work_featured_video_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND additional_resource_id IS NULL) + ); + +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'additional_resource' AND additional_resource_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'work_featured_video' AND work_featured_video_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND additional_resource_id IS NULL) + ); diff --git a/thoth-api/migrations/20260505_v1.2.1/up.sql b/thoth-api/migrations/20260505_v1.2.1/up.sql new file mode 100644 index 00000000..26110226 --- /dev/null +++ b/thoth-api/migrations/20260505_v1.2.1/up.sql @@ -0,0 +1,25 @@ +CREATE UNIQUE INDEX file_accessibility_report_publication_unique_idx ON public.file USING btree (publication_id) WHERE (file_type = 'accessibility_report'::public.file_type); +CREATE INDEX file_upload_accessibility_report_publication_idx ON public.file_upload USING btree (publication_id) WHERE (file_type = 'accessibility_report'::public.file_type); + +ALTER TABLE file DROP CONSTRAINT IF EXISTS file_type_check; +ALTER TABLE file_upload DROP CONSTRAINT IF EXISTS file_upload_type_check; + +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'additional_resource' AND additional_resource_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'work_featured_video' AND work_featured_video_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND additional_resource_id IS NULL) OR + (file_type = 'accessibility_report' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) + ); + +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'additional_resource' AND additional_resource_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND work_featured_video_id IS NULL) OR + (file_type = 'work_featured_video' AND work_featured_video_id IS NOT NULL AND work_id IS NULL AND publication_id IS NULL AND additional_resource_id IS NULL) OR + (file_type = 'accessibility_report' AND publication_id IS NOT NULL AND work_id IS NULL AND additional_resource_id IS NULL AND work_featured_video_id IS NULL) + ); diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 3c09f228..54b7bf5c 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -1012,6 +1012,12 @@ impl Publication { self.accessibility_report_url.as_ref() } + #[graphql(description = "Get the accessibility report file for this publication")] + pub fn accessibility_report(&self, context: &Context) -> FieldResult> { + File::from_publication_id(&context.db, &self.publication_id, FileType::A11yReport) + .map_err(Into::into) + } + #[graphql(description = "Get prices linked to this publication")] pub fn prices( &self, @@ -1082,7 +1088,8 @@ impl Publication { #[graphql(description = "Get the publication file for this publication")] pub fn file(&self, context: &Context) -> FieldResult> { - File::from_publication_id(&context.db, &self.publication_id).map_err(Into::into) + File::from_publication_id(&context.db, &self.publication_id, FileType::Publication) + .map_err(Into::into) } #[graphql(description = "Get the work to which this publication belongs")] @@ -1113,7 +1120,9 @@ impl File { self.work_id.as_ref() } - #[graphql(description = "Thoth ID of the publication (for publication files)")] + #[graphql( + description = "Thoth ID of the publication (for publication files and accessibility reports)" + )] pub fn publication_id(&self) -> Option<&Uuid> { self.publication_id.as_ref() } diff --git a/thoth-api/src/graphql/mutation.rs b/thoth-api/src/graphql/mutation.rs index 33086dbf..940bdac1 100644 --- a/thoth-api/src/graphql/mutation.rs +++ b/thoth-api/src/graphql/mutation.rs @@ -18,8 +18,8 @@ use crate::model::{ endorsement::{Endorsement, EndorsementPolicy, NewEndorsement, PatchEndorsement}, file::{ CompleteFileUpload, File, FilePolicy, FileUpload, FileUploadResponse, - NewAdditionalResourceFileUpload, NewFileUpload, NewFrontcoverFileUpload, - NewPublicationFileUpload, NewWorkFeaturedVideoFileUpload, + NewA11yReportFileUpload, NewAdditionalResourceFileUpload, NewFileUpload, + NewFrontcoverFileUpload, NewPublicationFileUpload, NewWorkFeaturedVideoFileUpload, }, funding::{Funding, FundingPolicy, NewFunding, PatchFunding}, imprint::{Imprint, ImprintPolicy, NewImprint, PatchImprint}, @@ -45,7 +45,8 @@ use crate::model::{ Crud, Reorder, }; use crate::policy::{ - CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy, UserAccess, + CreatePolicy, DeletePolicy, HostedFileSyncContext, MovePolicy, PolicyContext, UpdatePolicy, + UserAccess, }; use crate::storage::{ additional_resource_cleanup_plan, build_cdn_url, copy_temp_object_to_final, delete_object, @@ -1403,6 +1404,31 @@ impl MutationRoot { .map_err(Into::into) } + #[graphql( + description = "Start uploading an accessibility report for a given publication. Returns an upload session ID, a presigned S3 PUT URL, and required PUT headers." + )] + async fn init_a11yreport_file_upload( + context: &Context, + #[graphql(description = "Input for starting an accessibility report upload")] + data: NewA11yReportFileUpload, + ) -> FieldResult { + let publication: Publication = context.load_current(&data.publication_id)?; + + let new_upload: NewFileUpload = data.into(); + FilePolicy::can_create(context, &new_upload, Some(publication.publication_type))?; + + let work: Work = context.load_current(&publication.work_id)?; + work.doi.ok_or(ThothError::WorkMissingDoiForFileUpload)?; + + let imprint: Imprint = context.load_current(&work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + new_upload + .create_upload_response(&context.db, context.s3_client(), &storage_config, 30) + .await + .map_err(Into::into) + } + #[graphql( description = "Complete a file upload, validate it, and promote it to its final DOI-based location." )] @@ -1443,7 +1469,8 @@ impl MutationRoot { Some(ResourceType::Video) } crate::model::file::FileType::Frontcover - | crate::model::file::FileType::Publication => None, + | crate::model::file::FileType::Publication + | crate::model::file::FileType::A11yReport => None, }; FilePolicy::can_complete_upload( context, @@ -1488,8 +1515,9 @@ impl MutationRoot { &mime_type, bytes, )?; + let sync_context = HostedFileSyncContext::new(context); file_upload.sync_related_metadata( - context, + &sync_context, &work, &cdn_url, &file.sha256, diff --git a/thoth-api/src/model/additional_resource/policy.rs b/thoth-api/src/model/additional_resource/policy.rs index 7f206d57..7e6c7cdd 100644 --- a/thoth-api/src/model/additional_resource/policy.rs +++ b/thoth-api/src/model/additional_resource/policy.rs @@ -1,6 +1,7 @@ use crate::model::additional_resource::{ AdditionalResource, NewAdditionalResource, PatchAdditionalResource, }; +use crate::model::file::File; use crate::model::work::{Work, WorkType}; use crate::model::Crud; use crate::policy::{CreatePolicy, DeletePolicy, MovePolicy, PolicyContext, UpdatePolicy}; @@ -8,7 +9,10 @@ use thoth_errors::{ThothError, ThothResult}; /// Write policies for `AdditionalResource`. /// -/// These policies enforce publisher scoping and prevent attachment to chapter records. +/// These policies are responsible for: +/// - enforcing publisher scoping +/// - preventing attachment to chapter records +/// - preventing manual update of auto-generated Thoth Hosting URLs pub struct AdditionalResourcePolicy; fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { @@ -20,6 +24,18 @@ fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResu } } +fn ensure_no_hosted_file( + db: &crate::db::PgPool, + additional_resource_id: uuid::Uuid, +) -> ThothResult<()> { + let file = File::from_additional_resource_id(db, &additional_resource_id)?; + if file.is_some() { + Err(ThothError::HostedFileUrlEditError) + } else { + Ok(()) + } +} + impl CreatePolicy for AdditionalResourcePolicy { fn can_create( ctx: &C, @@ -41,7 +57,12 @@ impl UpdatePolicy for AdditionalRes ctx.require_publisher_for(current)?; ctx.require_publisher_for(patch)?; ensure_work_is_book(ctx.db(), current.work_id)?; - ensure_work_is_book(ctx.db(), patch.work_id) + ensure_work_is_book(ctx.db(), patch.work_id)?; + + if patch.url != current.url && !ctx.allow_hosted_file_url_update() { + ensure_no_hosted_file(ctx.db(), current.additional_resource_id)?; + } + Ok(()) } } diff --git a/thoth-api/src/model/file/crud.rs b/thoth-api/src/model/file/crud.rs index 1c5789a0..d0151e39 100644 --- a/thoth-api/src/model/file/crud.rs +++ b/thoth-api/src/model/file/crud.rs @@ -6,7 +6,7 @@ use crate::db::PgPool; use crate::model::{ additional_resource::{AdditionalResource, PatchAdditionalResource}, location::{Location, LocationPlatform, NewLocation, PatchLocation}, - publication::Publication, + publication::{PatchPublication, Publication}, work::{PatchWork, Work}, work_featured_video::{PatchWorkFeaturedVideo, WorkFeaturedVideo}, Crud, Doi, PublisherId, Timestamp, @@ -14,8 +14,8 @@ use crate::model::{ use crate::policy::{CreatePolicy, PolicyContext}; use crate::schema::{file, file_upload}; use crate::storage::{ - canonical_frontcover_key, canonical_publication_key, canonical_resource_key, - presign_put_for_upload, temp_key, S3Client, StorageConfig, + canonical_a11yreport_key, canonical_frontcover_key, canonical_publication_key, + canonical_resource_key, presign_put_for_upload, temp_key, S3Client, StorageConfig, }; use chrono::{Duration, Utc}; use diesel::prelude::*; @@ -490,13 +490,17 @@ impl File { .map_err(ThothError::from) } - pub fn from_publication_id(db: &PgPool, publication_id: &Uuid) -> ThothResult> { + pub fn from_publication_id( + db: &PgPool, + publication_id: &Uuid, + file_type: FileType, + ) -> ThothResult> { use crate::schema::file::dsl; let mut connection = db.get()?; dsl::file .filter(dsl::publication_id.eq(publication_id)) - .filter(dsl::file_type.eq(FileType::Publication)) + .filter(dsl::file_type.eq(file_type)) .first::(&mut connection) .optional() .map_err(ThothError::from) @@ -601,6 +605,14 @@ impl FileUpload { let work: Work = ctx.load_current(&work_featured_video.work_id)?; Ok((work, None, None, Some(work_featured_video))) } + FileType::A11yReport => { + let publication_id = self + .publication_id + .ok_or(ThothError::AccessibilityReportFileUploadMissingPublicationId)?; + let publication: Publication = ctx.load_current(&publication_id)?; + let work: Work = ctx.load_current(&publication.work_id)?; + Ok((work, Some(publication), None, None)) + } } } @@ -641,6 +653,17 @@ impl FileUpload { &self.declared_extension, )) } + FileType::A11yReport => { + let publication_id = self + .publication_id + .ok_or(ThothError::AccessibilityReportFileUploadMissingPublicationId); + Ok(canonical_a11yreport_key( + doi_prefix, + doi_suffix, + &publication_id?, + &self.declared_extension, + )) + } } } @@ -650,7 +673,7 @@ impl FileUpload { let publication_id = self .publication_id .ok_or(ThothError::PublicationFileUploadMissingPublicationId)?; - File::from_publication_id(db, &publication_id) + File::from_publication_id(db, &publication_id, FileType::Publication) } FileType::Frontcover => { let work_id = self @@ -670,6 +693,12 @@ impl FileUpload { .ok_or(ThothError::WorkFeaturedVideoFileUploadMissingWorkFeaturedVideoId)?; File::from_work_featured_video_id(db, &work_featured_video_id) } + FileType::A11yReport => { + let publication_id = self + .publication_id + .ok_or(ThothError::AccessibilityReportFileUploadMissingPublicationId)?; + File::from_publication_id(db, &publication_id, FileType::A11yReport) + } } } @@ -783,6 +812,15 @@ impl FileUpload { }; work_featured_video.update(ctx, &patch)?; } + FileType::A11yReport => { + let publication_id = self + .publication_id + .ok_or(ThothError::AccessibilityReportFileUploadMissingPublicationId)?; + let publication: Publication = ctx.load_current(&publication_id)?; + let mut patch: PatchPublication = publication.clone().into(); + patch.accessibility_report_url = Some(cdn_url.to_string()); + publication.update(ctx, &patch)?; + } } Ok(()) diff --git a/thoth-api/src/model/file/mod.rs b/thoth-api/src/model/file/mod.rs index c45ce2a7..9c218b41 100644 --- a/thoth-api/src/model/file/mod.rs +++ b/thoth-api/src/model/file/mod.rs @@ -48,6 +48,13 @@ pub enum FileType { )] #[strum(serialize = "work_featured_video")] WorkFeaturedVideo, + #[cfg_attr( + feature = "backend", + db_rename = "accessibility_report", + graphql(description = "Accessibility report") + )] + #[strum(serialize = "accessibility_report")] + A11yReport, } #[cfg_attr( @@ -216,6 +223,24 @@ pub struct NewWorkFeaturedVideoFileUpload { pub declared_sha256: String, } +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting an upload for an accessibility report.")] +pub struct NewA11yReportFileUpload { + #[graphql(description = "Thoth ID of the publication linked to this file.")] + pub publication_id: Uuid, + #[graphql( + description = "MIME type declared by the client (used for validation and in the presigned URL)." + )] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'html', 'pdf'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + #[cfg(feature = "backend")] #[derive(juniper::GraphQLInputObject)] #[graphql( @@ -343,6 +368,22 @@ impl From for NewFileUpload { } } +#[cfg(feature = "backend")] +impl From for NewFileUpload { + fn from(data: NewA11yReportFileUpload) -> Self { + NewFileUpload { + file_type: FileType::A11yReport, + work_id: None, + publication_id: Some(data.publication_id), + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: data.declared_mime_type, + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256, + } + } +} + #[cfg(feature = "backend")] pub mod crud; #[cfg(feature = "backend")] diff --git a/thoth-api/src/model/file/policy.rs b/thoth-api/src/model/file/policy.rs index 374c8add..c0910a43 100644 --- a/thoth-api/src/model/file/policy.rs +++ b/thoth-api/src/model/file/policy.rs @@ -11,6 +11,8 @@ const MIN_PUBLICATION_BYTES: i64 = 50 * KIB; const MAX_PUBLICATION_BYTES: i64 = 5 * GIB; const MIN_FRONTCOVER_BYTES: i64 = 50 * KIB; const MAX_FRONTCOVER_BYTES: i64 = 50 * MIB; +const MIN_A11YREPORT_BYTES: i64 = 50 * KIB; +const MAX_A11YREPORT_BYTES: i64 = 5 * MIB; const MIN_RESOURCE_BYTES: i64 = 1; const MAX_RESOURCE_BYTES: i64 = 5 * GIB; @@ -150,6 +152,12 @@ impl FilePolicy { return Err(ThothError::InvalidFileExtension); } } + FileType::A11yReport => { + let valid_extensions = ["html", "pdf"]; + if !valid_extensions.contains(&extension.to_lowercase().as_str()) { + return Err(ThothError::InvalidFileExtension); + } + } FileType::Publication => { if let Some(pub_type) = publication_type { let valid_extensions: Vec<&str> = match pub_type { @@ -212,6 +220,16 @@ impl FilePolicy { Err(ThothError::InvalidFileMimeType) } } + FileType::A11yReport => { + let accepted_mime_types: &[&str] = + &["text/html", "application/pdf", "application/octet-stream"]; + + if accepted_mime_types.contains(&mime_type.as_str()) { + Ok(()) + } else { + Err(ThothError::InvalidFileMimeType) + } + } FileType::Publication => { let publication_type = publication_type.ok_or(ThothError::PublicationTypeRequiredForFileValidation)?; @@ -273,6 +291,7 @@ impl FilePolicy { let (min_bytes, max_bytes) = match file_type { FileType::Publication => (MIN_PUBLICATION_BYTES, MAX_PUBLICATION_BYTES), FileType::Frontcover => (MIN_FRONTCOVER_BYTES, MAX_FRONTCOVER_BYTES), + FileType::A11yReport => (MIN_A11YREPORT_BYTES, MAX_A11YREPORT_BYTES), FileType::AdditionalResource | FileType::WorkFeaturedVideo => { (MIN_RESOURCE_BYTES, MAX_RESOURCE_BYTES) } @@ -300,7 +319,7 @@ impl FilePolicy { ) -> ThothResult<()> { Self::can_delete(ctx, upload)?; match upload.file_type { - FileType::Frontcover | FileType::Publication => { + FileType::Frontcover | FileType::Publication | FileType::A11yReport => { Self::validate_file_extension( &upload.declared_extension, &upload.file_type, diff --git a/thoth-api/src/model/file/tests.rs b/thoth-api/src/model/file/tests.rs index 70d24946..9f748db8 100644 --- a/thoth-api/src/model/file/tests.rs +++ b/thoth-api/src/model/file/tests.rs @@ -102,6 +102,23 @@ fn make_new_work_featured_video_upload( } } +#[cfg(feature = "backend")] +fn make_new_accessibility_report_upload( + publication_id: Uuid, + extension: impl Into, +) -> NewFileUpload { + NewFileUpload { + file_type: FileType::A11yReport, + work_id: None, + publication_id: Some(publication_id), + additional_resource_id: None, + work_featured_video_id: None, + declared_mime_type: "text/html".to_string(), + declared_extension: extension.into(), + declared_sha256: TEST_SHA256_HEX.to_string(), + } +} + #[cfg(feature = "backend")] fn create_pdf_publication( pool: &crate::db::PgPool, @@ -216,6 +233,26 @@ fn make_new_work_featured_video_file( } } +#[cfg(feature = "backend")] +fn make_new_accessibility_report_file( + publication_id: Uuid, + object_key: impl Into, +) -> NewFile { + let object_key = object_key.into(); + NewFile { + file_type: FileType::A11yReport, + work_id: None, + publication_id: Some(publication_id), + additional_resource_id: None, + work_featured_video_id: None, + object_key: object_key.clone(), + cdn_url: format!("https://cdn.example.org/{object_key}"), + mime_type: "text/html".to_string(), + bytes: 4096, + sha256: TEST_SHA256_HEX.to_string(), + } +} + mod display_and_parse { use super::*; @@ -231,6 +268,7 @@ mod display_and_parse { format!("{}", FileType::WorkFeaturedVideo), "work_featured_video" ); + assert_eq!(format!("{}", FileType::A11yReport), "accessibility_report"); } #[test] @@ -253,6 +291,10 @@ mod display_and_parse { FileType::from_str("work_featured_video").unwrap(), FileType::WorkFeaturedVideo ); + assert_eq!( + FileType::from_str("accessibility_report").unwrap(), + FileType::A11yReport + ); assert!(FileType::from_str("Publication").is_err()); assert!(FileType::from_str("cover").is_err()); } @@ -270,6 +312,7 @@ mod conversions { assert_graphql_enum_roundtrip(FileType::Frontcover); assert_graphql_enum_roundtrip(FileType::AdditionalResource); assert_graphql_enum_roundtrip(FileType::WorkFeaturedVideo); + assert_graphql_enum_roundtrip(FileType::A11yReport); } #[test] @@ -296,6 +339,11 @@ mod conversions { "'work_featured_video'::file_type", FileType::WorkFeaturedVideo, ); + assert_db_enum_roundtrip::( + pool.as_ref(), + "'accessibility_report'::file_type", + FileType::A11yReport, + ); } } @@ -397,6 +445,21 @@ mod validation { ); } + #[test] + fn accessibility_report_allows_known_extensions() { + for ext in ["html", "pdf"] { + assert!(FilePolicy::validate_file_extension(ext, &FileType::A11yReport, None).is_ok()); + } + } + + #[test] + fn accessibility_report_rejects_unknown_extensions() { + assert_eq!( + FilePolicy::validate_file_extension("mp3", &FileType::A11yReport, None).unwrap_err(), + ThothError::InvalidFileExtension + ); + } + #[test] fn publication_requires_publication_type_for_validation() { assert_eq!( @@ -484,6 +547,42 @@ mod validation { ); } + #[test] + fn accessibility_report_mime_allows_accepted_aliases() { + assert!(FilePolicy::validate_file_mime_type( + "html", + &FileType::A11yReport, + None, + "text/html" + ) + .is_ok()); + + assert!(FilePolicy::validate_file_mime_type( + "pdf", + &FileType::A11yReport, + None, + "application/octet-stream" + ) + .is_ok()); + + assert!(FilePolicy::validate_file_mime_type( + "pdf", + &FileType::A11yReport, + None, + "application/pdf" + ) + .is_ok()); + } + + #[test] + fn accessibility_report_mime_rejects_invalid_values() { + assert_eq!( + FilePolicy::validate_file_mime_type("mp3", &FileType::A11yReport, None, "audio/mp3") + .unwrap_err(), + ThothError::InvalidFileMimeType + ); + } + #[test] fn publication_size_limits_are_enforced() { let fifty_kib = 50 * 1024; @@ -518,6 +617,23 @@ mod validation { ); } + #[test] + fn accessibility_report_size_limits_are_enforced() { + let fifty_kib = 50 * 1024; + let five_mib = 5 * 1024 * 1024; + assert!(FilePolicy::validate_file_size(fifty_kib, &FileType::A11yReport).is_ok()); + assert!(FilePolicy::validate_file_size(five_mib, &FileType::A11yReport).is_ok()); + + assert_eq!( + FilePolicy::validate_file_size(fifty_kib - 1, &FileType::A11yReport).unwrap_err(), + ThothError::FileTooSmall + ); + assert_eq!( + FilePolicy::validate_file_size(five_mib + 1, &FileType::A11yReport).unwrap_err(), + ThothError::FileTooLarge + ); + } + #[test] fn new_file_upload_from_publication_lowercases_extension() { let data = NewPublicationFileUpload { @@ -560,6 +676,20 @@ mod validation { assert_eq!(upload.declared_extension, "png"); } + #[test] + fn new_file_upload_from_accessibility_report_lowercases_extension() { + let data = NewA11yReportFileUpload { + publication_id: Uuid::new_v4(), + declared_mime_type: "text/html".to_string(), + declared_extension: "HTML".to_string(), + declared_sha256: TEST_SHA256_HEX.to_string(), + }; + + let upload: NewFileUpload = data.into(); + assert_eq!(upload.file_type, FileType::A11yReport); + assert_eq!(upload.declared_extension, "html"); + } + #[test] fn resource_extension_and_mime_validation() { assert!(FilePolicy::validate_resource_file_extension("mp4", ResourceType::Video).is_ok()); @@ -890,6 +1020,20 @@ mod crud { ) .expect("Failed to create publication file upload"); + let accessibility_file = File::create( + pool.as_ref(), + &make_new_accessibility_report_file( + publication.publication_id, + format!("10.1234/{}/resources/a11yreport.html", Uuid::new_v4()), + ), + ) + .expect("Failed to create accessibility-report file"); + let accessibility_upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create accessibility-report upload"); + publication .delete(pool.as_ref()) .expect("Failed to delete publication"); @@ -897,6 +1041,8 @@ mod crud { assert!(Publication::from_id(pool.as_ref(), &publication_id).is_err()); assert!(File::from_id(pool.as_ref(), &file.file_id).is_err()); assert!(FileUpload::from_id(pool.as_ref(), &upload.file_upload_id).is_err()); + assert!(File::from_id(pool.as_ref(), &accessibility_file.file_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &accessibility_upload.file_upload_id).is_err()); } #[test] @@ -943,6 +1089,14 @@ mod crud { ), ) .expect("Failed to create featured-video file"); + let accessibility_file = File::create( + pool.as_ref(), + &make_new_accessibility_report_file( + publication.publication_id, + format!("10.1234/{}/resources/a11yreport.html", Uuid::new_v4()), + ), + ) + .expect("Failed to create accessibility-report file"); let cover_upload = FileUpload::create( pool.as_ref(), @@ -967,6 +1121,11 @@ mod crud { &make_new_work_featured_video_upload(featured_video.work_featured_video_id, "mp4"), ) .expect("Failed to create featured-video upload"); + let accessibility_upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create accessibility-report upload"); work.delete(pool.as_ref()).expect("Failed to delete work"); @@ -975,10 +1134,12 @@ mod crud { assert!(File::from_id(pool.as_ref(), &publication_file.file_id).is_err()); assert!(File::from_id(pool.as_ref(), &resource_file.file_id).is_err()); assert!(File::from_id(pool.as_ref(), &featured_file.file_id).is_err()); + assert!(File::from_id(pool.as_ref(), &accessibility_file.file_id).is_err()); assert!(FileUpload::from_id(pool.as_ref(), &cover_upload.file_upload_id).is_err()); assert!(FileUpload::from_id(pool.as_ref(), &publication_upload.file_upload_id).is_err()); assert!(FileUpload::from_id(pool.as_ref(), &resource_upload.file_upload_id).is_err()); assert!(FileUpload::from_id(pool.as_ref(), &featured_upload.file_upload_id).is_err()); + assert!(FileUpload::from_id(pool.as_ref(), &accessibility_upload.file_upload_id).is_err()); } #[test] @@ -1016,10 +1177,13 @@ mod crud { .expect("Expected frontcover file"); assert_eq!(from_work.file_id, frontcover_file.file_id); - let from_publication = - File::from_publication_id(pool.as_ref(), &publication.publication_id) - .expect("Failed to fetch publication file by publication id") - .expect("Expected publication file"); + let from_publication = File::from_publication_id( + pool.as_ref(), + &publication.publication_id, + FileType::Publication, + ) + .expect("Failed to fetch publication file by publication id") + .expect("Expected publication file"); assert_eq!(from_publication.file_id, publication_file.file_id); let other_work = create_work(pool.as_ref(), &imprint); @@ -1027,11 +1191,13 @@ mod crud { assert!(File::from_work_id(pool.as_ref(), &other_work.work_id) .expect("Failed to query frontcover by work id") .is_none()); - assert!( - File::from_publication_id(pool.as_ref(), &other_publication.publication_id) - .expect("Failed to query publication file by publication id") - .is_none() - ); + assert!(File::from_publication_id( + pool.as_ref(), + &other_publication.publication_id, + FileType::Publication + ) + .expect("Failed to query publication file by publication id") + .is_none()); } #[test] @@ -1051,6 +1217,10 @@ mod crud { publication.publication_id, format!("10.1234/{}/publication.pdf", Uuid::new_v4()), ); + let accessibility_report_new_file = make_new_accessibility_report_file( + publication.publication_id, + format!("10.1234/{}/resources/a11yreport.html", Uuid::new_v4()), + ); assert_eq!( frontcover_new_file.publisher_id(pool.as_ref()).unwrap(), @@ -1060,6 +1230,12 @@ mod crud { publication_new_file.publisher_id(pool.as_ref()).unwrap(), publisher.publisher_id ); + assert_eq!( + accessibility_report_new_file + .publisher_id(pool.as_ref()) + .unwrap(), + publisher.publisher_id + ); let frontcover_file = File::create(pool.as_ref(), &frontcover_new_file).expect("Failed to create file"); @@ -1068,6 +1244,8 @@ mod crud { &make_new_publication_upload(publication.publication_id, "pdf"), ) .expect("Failed to create file upload"); + let accessibility_report_file = File::create(pool.as_ref(), &accessibility_report_new_file) + .expect("Failed to create file"); assert_eq!( frontcover_file.publisher_id(pool.as_ref()).unwrap(), @@ -1077,6 +1255,12 @@ mod crud { publication_upload.publisher_id(pool.as_ref()).unwrap(), publisher.publisher_id ); + assert_eq!( + accessibility_report_file + .publisher_id(pool.as_ref()) + .unwrap(), + publisher.publisher_id + ); let invalid_new_file = NewFile { file_type: FileType::Frontcover, @@ -1130,6 +1314,11 @@ mod crud { &make_new_frontcover_upload(work.work_id, "jpg"), ) .expect("Failed to create frontcover upload"); + let accessibility_report_upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create accessibility-report upload"); let ctx = test_context(pool.clone(), "file-user"); @@ -1156,6 +1345,20 @@ mod crud { assert!(loaded_resource.is_none()); assert!(loaded_featured_video.is_none()); + let (loaded_work, loaded_publication, loaded_resource, loaded_featured_video) = + accessibility_report_upload + .load_scope(&ctx) + .expect("Failed to load accessibility reportt upload scope"); + assert_eq!(loaded_work.work_id, work.work_id); + assert_eq!( + loaded_publication + .expect("Expected publication to be loaded") + .publication_id, + publication.publication_id + ); + assert!(loaded_resource.is_none()); + assert!(loaded_featured_video.is_none()); + let doi = Doi::from_str("https://doi.org/10.1234/AbC/Def").expect("Failed to parse DOI"); assert_eq!( publication_upload.canonical_key(&doi).unwrap(), @@ -1297,6 +1500,43 @@ mod crud { assert_eq!(refreshed.height, 720); } + #[test] + fn crud_sync_related_metadata_updates_publication_accessibility_report_url() { + let (_guard, pool) = setup_test_db(); + + let publisher = create_publisher(pool.as_ref()); + let imprint = create_imprint(pool.as_ref(), &publisher); + let work = create_work(pool.as_ref(), &imprint); + let publication = create_pdf_publication(pool.as_ref(), work.work_id); + + let org_id = publisher + .zitadel_id + .clone() + .expect("publisher missing zitadel id"); + let user = test_user_with_role("file-user", Role::PublisherUser, &org_id); + let ctx = test_context_with_user(pool.clone(), user); + + let upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create upload"); + + let accessibility_report_url = + "https://cdn.example.org/10.1234/abc/resources/def_a11yreport.html"; + upload + .sync_related_metadata(&ctx, &work, accessibility_report_url, "checksum", None) + .expect("Failed to sync accessibility report metadata"); + + let refreshed_publication = + Publication::from_id(pool.as_ref(), &publication.publication_id) + .expect("Failed to reload publication after metadata sync"); + assert_eq!( + refreshed_publication.accessibility_report_url.as_deref(), + Some(accessibility_report_url) + ); + } + #[test] fn cleanup_candidates_for_publication_includes_file_and_pending_upload() { let (_guard, pool) = setup_test_db(); @@ -1317,17 +1557,39 @@ mod crud { ) .expect("Failed to create publication upload"); + let accessibility_report_key = + format!("10.1234/{}/resources/a11yreport.html", Uuid::new_v4()); + File::create( + pool.as_ref(), + &make_new_accessibility_report_file( + publication.publication_id, + &accessibility_report_key, + ), + ) + .expect("Failed to create accessibility-report file"); + let accessibility_report_upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create accessibility-report upload"); + let candidates = File::cleanup_candidates_for_publication(pool.as_ref(), &publication.publication_id) .expect("Failed to load publication cleanup candidates"); - assert_eq!(candidates.len(), 2); + assert_eq!(candidates.len(), 4); assert!(candidates .iter() .any(|c| c.file_type == FileType::Publication && c.object_key == object_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == accessibility_report_key)); assert!(candidates .iter() .any(|c| c.file_type == FileType::Publication && c.object_key == temp_key(&upload.file_upload_id))); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&accessibility_report_upload.file_upload_id))); } #[test] @@ -1448,6 +1710,8 @@ mod crud { let featured_video_key = format!("10.1234/{}/resources/featured.mp4", Uuid::new_v4()); let publication_key = format!("10.1234/{}/publication.pdf", Uuid::new_v4()); let cover_key = format!("10.1234/{}/cover.jpg", Uuid::new_v4()); + let accessibility_report_key = + format!("10.1234/{}/resources/a11yreport.html", Uuid::new_v4()); File::create( pool.as_ref(), @@ -1475,6 +1739,14 @@ mod crud { ), ) .expect("Failed to create featured-video file"); + File::create( + pool.as_ref(), + &make_new_accessibility_report_file( + publication.publication_id, + &accessibility_report_key, + ), + ) + .expect("Failed to create accessibility-report file"); let cover_upload = FileUpload::create( pool.as_ref(), &make_new_frontcover_upload(work.work_id, "jpg"), @@ -1498,17 +1770,25 @@ mod crud { &make_new_work_featured_video_upload(featured_video.work_featured_video_id, "mp4"), ) .expect("Failed to create featured-video upload"); + let accessibility_report_upload = FileUpload::create( + pool.as_ref(), + &make_new_accessibility_report_upload(publication.publication_id, "html"), + ) + .expect("Failed to create accessibility-report upload"); let candidates = File::cleanup_candidates_for_work(pool.as_ref(), &work.work_id) .expect("Failed to load"); - assert_eq!(candidates.len(), 8); + assert_eq!(candidates.len(), 10); assert!(candidates.iter().any(|c| c.object_key == cover_key)); assert!(candidates.iter().any(|c| c.object_key == publication_key)); assert!(candidates.iter().any(|c| c.object_key == resource_key)); assert!(candidates .iter() .any(|c| c.object_key == featured_video_key)); + assert!(candidates + .iter() + .any(|c| c.object_key == accessibility_report_key)); assert!(candidates .iter() .any(|c| c.object_key == temp_key(&cover_upload.file_upload_id))); @@ -1521,5 +1801,8 @@ mod crud { assert!(candidates .iter() .any(|c| c.object_key == temp_key(&featured_video_upload.file_upload_id))); + assert!(candidates + .iter() + .any(|c| c.object_key == temp_key(&accessibility_report_upload.file_upload_id))); } } diff --git a/thoth-api/src/model/publication/mod.rs b/thoth-api/src/model/publication/mod.rs index 159b4d3e..db78cb82 100644 --- a/thoth-api/src/model/publication/mod.rs +++ b/thoth-api/src/model/publication/mod.rs @@ -505,6 +505,28 @@ macro_rules! publication_properties { publication_properties!(Publication); publication_properties!(NewPublication); publication_properties!(PatchPublication); +impl From for PatchPublication { + fn from(p: Publication) -> Self { + Self { + publication_id: p.publication_id, + publication_type: p.publication_type, + work_id: p.work_id, + isbn: p.isbn, + width_mm: p.width_mm, + width_in: p.width_in, + height_mm: p.height_mm, + height_in: p.height_in, + depth_mm: p.depth_mm, + depth_in: p.depth_in, + weight_g: p.weight_g, + weight_oz: p.weight_oz, + accessibility_standard: p.accessibility_standard, + accessibility_additional_standard: p.accessibility_additional_standard, + accessibility_exception: p.accessibility_exception, + accessibility_report_url: p.accessibility_report_url, + } + } +} #[cfg(feature = "backend")] pub mod crud; diff --git a/thoth-api/src/model/publication/policy.rs b/thoth-api/src/model/publication/policy.rs index 58409b2f..5867def2 100644 --- a/thoth-api/src/model/publication/policy.rs +++ b/thoth-api/src/model/publication/policy.rs @@ -1,4 +1,5 @@ use crate::model::{ + file::{File, FileType}, publication::{NewPublication, PatchPublication, Publication, PublicationProperties}, work::{Work, WorkProperties}, Crud, @@ -11,8 +12,18 @@ use thoth_errors::{ThothError, ThothResult}; /// These policies are responsible for: /// - requiring authentication /// - requiring publisher membership (tenant boundary) +/// - preventing manual update of auto-generated Thoth Hosting URLs pub struct PublicationPolicy; +fn ensure_no_hosted_file(db: &crate::db::PgPool, publication_id: uuid::Uuid) -> ThothResult<()> { + let file = File::from_publication_id(db, &publication_id, FileType::A11yReport)?; + if file.is_some() { + Err(ThothError::HostedFileUrlEditError) + } else { + Ok(()) + } +} + impl CreatePolicy for PublicationPolicy { fn can_create( ctx: &C, @@ -34,6 +45,12 @@ impl UpdatePolicy for PublicationPolicy { ctx.require_publisher_for(current)?; ctx.require_publisher_for(patch)?; + if patch.accessibility_report_url != current.accessibility_report_url + && !ctx.allow_hosted_file_url_update() + { + ensure_no_hosted_file(ctx.db(), current.publication_id)?; + } + patch.validate(ctx.db()) } } diff --git a/thoth-api/src/model/work/policy.rs b/thoth-api/src/model/work/policy.rs index ff13a540..da230072 100644 --- a/thoth-api/src/model/work/policy.rs +++ b/thoth-api/src/model/work/policy.rs @@ -1,3 +1,4 @@ +use crate::model::file::File; use crate::model::work::{NewWork, PatchWork, Work, WorkProperties, WorkType}; use crate::policy::{CreatePolicy, DeletePolicy, PolicyContext, UpdatePolicy, UserAccess}; use thoth_errors::{ThothError, ThothResult}; @@ -7,8 +8,18 @@ use thoth_errors::{ThothError, ThothResult}; /// This policy layer enforces: /// - authentication /// - publisher membership derived from the entity / input via `PublisherId` +/// - preventing manual update of auto-generated Thoth Hosting URLs pub struct WorkPolicy; +fn ensure_no_hosted_file(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { + let file = File::from_work_id(db, &work_id)?; + if file.is_some() { + Err(ThothError::HostedFileUrlEditError) + } else { + Ok(()) + } +} + impl CreatePolicy for WorkPolicy { fn can_create(ctx: &C, data: &NewWork, _params: ()) -> ThothResult<()> { ctx.require_publisher_for(data)?; @@ -41,6 +52,10 @@ impl UpdatePolicy for WorkPolicy { ctx.require_work_lifecycle_for(patch)?; } + if patch.cover_url != current.cover_url && !ctx.allow_hosted_file_url_update() { + ensure_no_hosted_file(ctx.db(), current.work_id)?; + } + patch.validate()?; if current.is_published() && !patch.is_published() && !user.is_superuser() { diff --git a/thoth-api/src/model/work_featured_video/policy.rs b/thoth-api/src/model/work_featured_video/policy.rs index 9eedd680..04226dbf 100644 --- a/thoth-api/src/model/work_featured_video/policy.rs +++ b/thoth-api/src/model/work_featured_video/policy.rs @@ -1,3 +1,4 @@ +use crate::model::file::File; use crate::model::work::{Work, WorkType}; use crate::model::work_featured_video::{ NewWorkFeaturedVideo, PatchWorkFeaturedVideo, WorkFeaturedVideo, @@ -8,7 +9,10 @@ use thoth_errors::{ThothError, ThothResult}; /// Write policies for `WorkFeaturedVideo`. /// -/// These policies enforce publisher scoping and prevent attachment to chapter records. +/// These policies are responsible for: +/// - enforcing publisher scoping +/// - preventing attachment to chapter records +/// - preventing manual update of auto-generated Thoth Hosting URLs pub struct WorkFeaturedVideoPolicy; fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResult<()> { @@ -20,6 +24,18 @@ fn ensure_work_is_book(db: &crate::db::PgPool, work_id: uuid::Uuid) -> ThothResu } } +fn ensure_no_hosted_file( + db: &crate::db::PgPool, + work_featured_video_id: uuid::Uuid, +) -> ThothResult<()> { + let file = File::from_work_featured_video_id(db, &work_featured_video_id)?; + if file.is_some() { + Err(ThothError::HostedFileUrlEditError) + } else { + Ok(()) + } +} + impl CreatePolicy for WorkFeaturedVideoPolicy { fn can_create( ctx: &C, @@ -41,7 +57,12 @@ impl UpdatePolicy for WorkFeaturedVid ctx.require_publisher_for(current)?; ctx.require_publisher_for(patch)?; ensure_work_is_book(ctx.db(), current.work_id)?; - ensure_work_is_book(ctx.db(), patch.work_id) + ensure_work_is_book(ctx.db(), patch.work_id)?; + + if patch.url != current.url && !ctx.allow_hosted_file_url_update() { + ensure_no_hosted_file(ctx.db(), current.work_featured_video_id)?; + } + Ok(()) } } diff --git a/thoth-api/src/policy.rs b/thoth-api/src/policy.rs index d3233c32..da5c23e9 100644 --- a/thoth-api/src/policy.rs +++ b/thoth-api/src/policy.rs @@ -117,6 +117,11 @@ pub(crate) trait PolicyContext { /// Return the authenticated user for the current request, if any. fn user(&self) -> Option<&IntrospectedUser>; + /// Return true while internally syncing metadata from a completed hosted file upload. + fn allow_hosted_file_url_update(&self) -> bool { + false + } + /// Require that a user is authenticated and return the authenticated user. /// /// # Errors @@ -237,6 +242,30 @@ pub(crate) trait PolicyContext { } } +pub(crate) struct HostedFileSyncContext<'a, C> { + inner: &'a C, +} + +impl<'a, C> HostedFileSyncContext<'a, C> { + pub(crate) fn new(inner: &'a C) -> Self { + Self { inner } + } +} + +impl PolicyContext for HostedFileSyncContext<'_, C> { + fn db(&self) -> &PgPool { + self.inner.db() + } + + fn user(&self) -> Option<&IntrospectedUser> { + self.inner.user() + } + + fn allow_hosted_file_url_update(&self) -> bool { + true + } +} + /// A policy for create actions. /// /// Some create operations require additional parameters beyond the `New*` input (e.g. markup diff --git a/thoth-api/src/storage/mod.rs b/thoth-api/src/storage/mod.rs index 293b0f90..742b1dca 100644 --- a/thoth-api/src/storage/mod.rs +++ b/thoth-api/src/storage/mod.rs @@ -710,6 +710,22 @@ pub fn canonical_resource_key( ) } +/// Compute the canonical object key for an accessibility report file +pub fn canonical_a11yreport_key( + doi_prefix: &str, + doi_suffix: &str, + publication_id: &Uuid, + extension: &str, +) -> String { + format!( + "{}/{}/resources/{}_a11yreport.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + publication_id, + extension.to_lowercase() + ) +} + /// Build the full CDN URL from domain and object key pub fn build_cdn_url(cdn_domain: &str, object_key: &str) -> String { // Ensure cdn_domain doesn't end with / and object_key doesn't have a leading / diff --git a/thoth-api/src/storage/tests.rs b/thoth-api/src/storage/tests.rs index aa1e64e8..cbee93ca 100644 --- a/thoth-api/src/storage/tests.rs +++ b/thoth-api/src/storage/tests.rs @@ -71,6 +71,16 @@ fn canonical_resource_key_uses_resource_subpath() { ); } +#[test] +fn canonical_a11yreport_key_uses_resource_subpath() { + let resource_id = Uuid::parse_str("0f97fb46-4ed2-4bc0-98dd-f2f8ce0ebe11").unwrap(); + let key = canonical_a11yreport_key("10.1234", "AbC/Def", &resource_id, "HTML"); + assert_eq!( + key, + "10.1234/abc/def/resources/0f97fb46-4ed2-4bc0-98dd-f2f8ce0ebe11_a11yreport.html" + ); +} + #[test] fn build_cdn_url_normalizes_domain_and_key() { let https_url = build_cdn_url("https://cdn.example.org/", "/files/doc.pdf"); diff --git a/thoth-errors/src/database_errors.rs b/thoth-errors/src/database_errors.rs index 0a39835d..4adaaa6a 100644 --- a/thoth-errors/src/database_errors.rs +++ b/thoth-errors/src/database_errors.rs @@ -76,6 +76,7 @@ static DATABASE_CONSTRAINT_ERRORS: Map<&'static str, &'static str> = phf_map! { "file_publication_unique_idx" => "A publication file for this publication already exists.", "file_additional_resource_unique_idx" => "A file for this additional resource already exists.", "file_work_featured_video_unique_idx" => "A file for this featured video already exists.", + "file_accessibility_report_publication_unique_idx" => "An accessibility report file for this publication already exists.", "file_type_check" => "File type is invalid: frontcover must have work_id, publication must have publication_id, additional_resource must have additional_resource_id, work_featured_video must have work_featured_video_id.", "file_upload_type_check" => "File upload type is invalid: frontcover must have work_id, publication must have publication_id, additional_resource must have additional_resource_id, work_featured_video must have work_featured_video_id.", "orcid_uniq_idx" => "A contributor with this ORCID ID already exists.", diff --git a/thoth-errors/src/lib.rs b/thoth-errors/src/lib.rs index 7afbc0ed..22b37329 100644 --- a/thoth-errors/src/lib.rs +++ b/thoth-errors/src/lib.rs @@ -166,6 +166,10 @@ pub enum ThothError { CreateLocationChecksumError, #[error("Only superusers can update or delete an existing Location Checksum.")] UpdateLocationChecksumError, + #[error("Accessibility report file upload missing publication_id")] + AccessibilityReportFileUploadMissingPublicationId, + #[error("URLs of uploaded files cannot be overwritten.")] + HostedFileUrlEditError, } impl ThothError {