Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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]
### Fixed
- [741](https://github.com/thoth-pub/thoth/pull/741) - Harden JATS rich-text handling by rejecting malformed or nested markup and abstract line breaks on write, and normalise Crossref abstract output to avoid invalid nested `jats:p` and `jats:break` elements

## [[1.0.2]](https://github.com/thoth-pub/thoth/releases/tag/v1.0.2) - 2026-04-03
### Security
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion thoth-api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jsonwebtoken = { version = "10.3.0", optional = true }
juniper = { version = "0.16.1", features = ["chrono", "schema-language", "uuid"] }
lazy_static = "1.5.0"
pulldown-cmark = "0.13.0"
quick-xml = "0.36"
rand = { version = "0.9.0", optional = true }
regex = "1.11.1"
scraper = "0.20.0"
Expand All @@ -64,4 +65,4 @@ log = "0.4.26"

[dev-dependencies]
fs2 = "0.4.3"
tokio = { version = "1.44", features = ["macros", "rt"] }
tokio = { version = "1.44", features = ["macros", "rt", "rt-multi-thread"] }
78 changes: 49 additions & 29 deletions thoth-api/src/graphql/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,11 +1195,19 @@ fn patch_title(title: &Title) -> PatchTitle {
}
}

fn append_to_jats_paragraph_content(content: &str, suffix: &str) -> String {
if let Some((head, tail)) = content.rsplit_once("</p>") {
format!("{head}{suffix}</p>{tail}")
} else {
format!("{content}{suffix}")
}
}

fn patch_abstract(abstract_item: &Abstract) -> PatchAbstract {
PatchAbstract {
abstract_id: abstract_item.abstract_id,
work_id: abstract_item.work_id,
content: format!("{} Updated", abstract_item.content),
content: append_to_jats_paragraph_content(&abstract_item.content, " Updated"),
locale_code: abstract_item.locale_code,
abstract_type: abstract_item.abstract_type,
canonical: abstract_item.canonical,
Expand All @@ -1210,7 +1218,7 @@ fn patch_biography(biography: &Biography) -> PatchBiography {
PatchBiography {
biography_id: biography.biography_id,
contribution_id: biography.contribution_id,
content: format!("{} Updated", biography.content),
content: append_to_jats_paragraph_content(&biography.content, " Updated"),
canonical: biography.canonical,
locale_code: biography.locale_code,
}
Expand Down Expand Up @@ -2551,7 +2559,7 @@ query LinkedRelations($reviewId: Uuid!, $endorsementId: Uuid!) {
}

#[test]
fn graphql_markup_mutations_accept_plain_text_when_markup_is_jats_xml() {
fn graphql_markup_mutations_accept_valid_jatsxml_but_reject_breaks_and_markup_like_plain_text() {
let (_guard, pool) = test_db::setup_test_db();
let schema = create_schema();
let superuser = test_db::test_superuser("user-jats-xml-mutations");
Expand Down Expand Up @@ -2597,12 +2605,17 @@ fn graphql_markup_mutations_accept_plain_text_when_markup_is_jats_xml() {
);

let abstract_item = Abstract::from_id(pool.as_ref(), &seed.abstract_short_id).unwrap();
update_with_data_and_markup(
&schema,
&context,
"updateAbstract",
"PatchAbstract",
"abstractId",
let abstract_query = r#"
mutation UpdateAbstract($data: PatchAbstract!, $markup: MarkupFormat!) {
updateAbstract(data: $data, markupFormat: $markup) {
abstractId
}
}
"#;
let mut abstract_vars = Variables::new();
insert_var(
&mut abstract_vars,
"data",
PatchAbstract {
abstract_id: abstract_item.abstract_id,
work_id: abstract_item.work_id,
Expand All @@ -2613,36 +2626,43 @@ fn graphql_markup_mutations_accept_plain_text_when_markup_is_jats_xml() {
abstract_type: abstract_item.abstract_type,
canonical: abstract_item.canonical,
},
MarkupFormat::PlainText,
);

let stored_abstract = Abstract::from_id(pool.as_ref(), &seed.abstract_short_id).unwrap();
assert_eq!(
stored_abstract.content,
"<p>First line<break/>Second line with <inline-formula><tex-math>E=mc^2</tex-math></inline-formula> and <email>user@example.org</email> and <uri>https://example.org</uri></p>"
insert_var(&mut abstract_vars, "markup", MarkupFormat::PlainText);
let (_, abstract_errors) =
juniper::execute_sync(abstract_query, None, &schema, &abstract_vars, &context)
.expect("GraphQL execution should succeed with validation errors");
assert!(
!abstract_errors.is_empty(),
"Expected abstract validation error"
);

let biography = Biography::from_id(pool.as_ref(), &seed.biography_id).unwrap();
update_with_data_and_markup(
&schema,
&context,
"updateBiography",
"PatchBiography",
"biographyId",
let biography_query = r#"
mutation UpdateBiography($data: PatchBiography!, $markup: MarkupFormat!) {
updateBiography(data: $data, markupFormat: $markup) {
biographyId
}
}
"#;
let mut biography_vars = Variables::new();
insert_var(
&mut biography_vars,
"data",
PatchBiography {
biography_id: biography.biography_id,
contribution_id: biography.contribution_id,
content: "<p>Bio line<break/><inline-formula><tex-math>x^2</tex-math></inline-formula> <email>bio@example.org</email> <uri>https://bio.example.org</uri></p>".to_string(),
canonical: biography.canonical,
locale_code: biography.locale_code,
},
MarkupFormat::JatsXml,
);

let stored_biography = Biography::from_id(pool.as_ref(), &seed.biography_id).unwrap();
assert_eq!(
stored_biography.content,
"<p>Bio line<break/><inline-formula><tex-math>x^2</tex-math></inline-formula> <email>bio@example.org</email> <uri>https://bio.example.org</uri></p>"
insert_var(&mut biography_vars, "markup", MarkupFormat::JatsXml);
let (_, biography_errors) =
juniper::execute_sync(biography_query, None, &schema, &biography_vars, &context)
.expect("GraphQL execution should succeed with validation errors");
assert!(
!biography_errors.is_empty(),
"Expected biography validation error"
);
}

Expand Down Expand Up @@ -3111,7 +3131,7 @@ fn graphql_mutations_cover_all() {
"PatchAbstract",
"abstractId",
patch_abstract(&abstract_item),
MarkupFormat::PlainText,
MarkupFormat::JatsXml,
);

let biography = Biography::from_id(pool.as_ref(), &seed.biography_id).unwrap();
Expand All @@ -3122,7 +3142,7 @@ fn graphql_mutations_cover_all() {
"PatchBiography",
"biographyId",
patch_biography(&biography),
MarkupFormat::PlainText,
MarkupFormat::JatsXml,
);

let work = Work::from_id(pool.as_ref(), &seed.book_work_id).unwrap();
Expand Down
Loading
Loading