Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions thoth-api/migrations/20260504_v1.2.1/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT 1;
1 change: 1 addition & 0 deletions thoth-api/migrations/20260504_v1.2.1/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TYPE file_type ADD VALUE IF NOT EXISTS 'accessibility_report';
26 changes: 26 additions & 0 deletions thoth-api/migrations/20260505_v1.2.1/down.sql
Original file line number Diff line number Diff line change
@@ -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
Comment thread
rhigman marked this conversation as resolved.
(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)
);
25 changes: 25 additions & 0 deletions thoth-api/migrations/20260505_v1.2.1/up.sql
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
rhigman marked this conversation as resolved.
);

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)
);
13 changes: 11 additions & 2 deletions thoth-api/src/graphql/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<File>> {
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,
Expand Down Expand Up @@ -1082,7 +1088,8 @@ impl Publication {

#[graphql(description = "Get the publication file for this publication")]
pub fn file(&self, context: &Context) -> FieldResult<Option<File>> {
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")]
Expand Down Expand Up @@ -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()
}
Expand Down
38 changes: 33 additions & 5 deletions thoth-api/src/graphql/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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,
Expand Down Expand Up @@ -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<FileUploadResponse> {
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."
)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions thoth-api/src/model/additional_resource/policy.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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};
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<()> {
Expand All @@ -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<NewAdditionalResource> for AdditionalResourcePolicy {
fn can_create<C: PolicyContext>(
ctx: &C,
Expand All @@ -41,7 +57,12 @@ impl UpdatePolicy<AdditionalResource, PatchAdditionalResource> 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(())
}
}

Expand Down
50 changes: 44 additions & 6 deletions thoth-api/src/model/file/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ 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,
};
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::*;
Expand Down Expand Up @@ -490,13 +490,17 @@ impl File {
.map_err(ThothError::from)
}

pub fn from_publication_id(db: &PgPool, publication_id: &Uuid) -> ThothResult<Option<Self>> {
pub fn from_publication_id(
db: &PgPool,
publication_id: &Uuid,
file_type: FileType,
) -> ThothResult<Option<Self>> {
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::<File>(&mut connection)
.optional()
.map_err(ThothError::from)
Expand Down Expand Up @@ -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))
}
}
}

Expand Down Expand Up @@ -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,
))
}
}
}

Expand All @@ -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
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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(())
Expand Down
41 changes: 41 additions & 0 deletions thoth-api/src/model/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -343,6 +368,22 @@ impl From<NewWorkFeaturedVideoFileUpload> for NewFileUpload {
}
}

#[cfg(feature = "backend")]
impl From<NewA11yReportFileUpload> 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")]
Expand Down
Loading
Loading