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 {