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
41 changes: 35 additions & 6 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio", "metrics", "te
opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "metrics"] }
sqlx = { version = "0.8", features = ["sqlite", "postgres", "migrate", "runtime-tokio", "chrono"] }
chrono = "0.4"
chrono-tz = "0.10.4"
croner = "2.2"
tempfile = "3"
rstest = "0.26"
Expand Down
15 changes: 7 additions & 8 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ async-trait.workspace = true
base64.workspace = true
email_address.workspace = true
chrono = { workspace = true, features = ["serde"] }
chrono-tz.workspace = true
croner.workspace = true
html5ever = { workspace = true, optional = true }
macros = { path = "../macros" }
orgize = { workspace = true, optional = true }
orgize.workspace = true
percent-encoding.workspace = true
pulldown-cmark = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
Expand Down Expand Up @@ -49,13 +50,11 @@ sqlx = ["dep:sqlx"]
# `html5ever` rides the same flag: `extract_media_refs` re-parses the sanitized output
# (#711), so it is meaningless without the sanitizer and must stay out of the wasm graph
# for the same reason `ammonia` does.
#
# `orgize` and `pulldown-cmark` ride it too (#836): the only code that calls them is
# `render_markdown`/`render_org` inside the `sanitize`-gated `sanitized` module, so
# declaring them non-optionally made them permanent members of every graph that never
# uses them. LTO already kept their code out of the wasm *binary* — this is manifest
# hygiene, so the dependency list stops claiming a coupling that does not exist.
sanitize = ["dep:ammonia", "dep:html5ever", "dep:orgize", "dep:pulldown-cmark"]
# `pulldown-cmark` rides this flag (#836): the only code that calls it is
# `render_markdown` inside the `sanitize`-gated `sanitized` module, so declaring it
# non-optionally made it a permanent member of every graph that never uses it. Org
# normalization uses `orgize` independently, so it remains a direct dependency.
sanitize = ["dep:ammonia", "dep:html5ever", "dep:pulldown-cmark"]
test-utils = ["cheap-kdf"]
cheap-kdf = []
# Compiles `common::test_support` (shared test fixtures) for downstream test crates;
Expand Down
70 changes: 59 additions & 11 deletions common/src/atompub/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,43 @@ fn prune_empty_extensions(entry: &mut Entry) {
// Draft flag (app:control/app:draft) helpers
// ---------------------------------------------------------------------------

/// Returns the explicit `app:control/app:draft` marker when present.
///
/// `Some(true)` is RFC 5023's `yes` value; any other explicit marker value is
/// `Some(false)`. This preserves the distinction between an explicit
/// non-draft marker and no marker, which callers that merge lifecycle sources
/// need. Multiple valid markers retain [`is_draft`]'s established meaning:
/// `yes` wins.
#[must_use]
pub fn draft_marker(entry: &Entry) -> Option<bool> {
markers_in(entry, APP_NS, "control")
.filter_map(|(_, control)| control_draft_marker(&entry.namespaces, control))
.reduce(|draft, marker| draft || marker)
}

/// Returns true when the entry carries `app:control/app:draft = yes`.
#[must_use]
pub fn is_draft(entry: &Entry) -> bool {
markers_in(entry, APP_NS, "control")
.any(|(_, control)| control_marks_draft(&entry.namespaces, control))
draft_marker(entry).unwrap_or(false)
}

fn control_marks_draft(namespaces: &BTreeMap<String, String>, control: &Extension) -> bool {
control.children.get("draft").is_some_and(|drafts| {
drafts.iter().any(|d| {
child_in_namespace(namespaces, &control.attrs, d, APP_NS)
&& d.value
.as_deref()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("yes"))
})
})
fn control_draft_marker(
namespaces: &BTreeMap<String, String>,
control: &Extension,
) -> Option<bool> {
let drafts = control.children.get("draft")?;
let mut found = false;
let mut is_draft = false;
for draft in drafts {
if child_in_namespace(namespaces, &control.attrs, draft, APP_NS) {
found = true;
is_draft |= draft
.value
.as_deref()
.is_some_and(|v| v.trim().eq_ignore_ascii_case("yes"));
}
}
found.then_some(is_draft)
}

/// Sets or clears the `app:control/app:draft` marker on an entry.
Expand Down Expand Up @@ -804,6 +825,33 @@ mod tests {
);
}

#[test]
fn draft_marker_preserves_explicit_non_draft_presence() {
let absent = r#"<entry xmlns="http://www.w3.org/2005/Atom"><title>T</title></entry>"#
.parse::<Entry>()
.expect("parse");
let explicit_no =
r#"<entry xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
<title>T</title>
<app:control><app:draft>no</app:draft></app:control>
</entry>"#
.parse::<Entry>()
.expect("parse");
let explicit_yes =
r#"<entry xmlns="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app">
<title>T</title>
<app:control><app:draft>yes</app:draft></app:control>
</entry>"#
.parse::<Entry>()
.expect("parse");

assert_eq!(draft_marker(&absent), None);
assert_eq!(draft_marker(&explicit_no), Some(false));
assert_eq!(draft_marker(&explicit_yes), Some(true));
assert!(!is_draft(&explicit_no));
assert!(is_draft(&explicit_yes));
}

#[test]
fn a_control_element_outside_the_app_namespace_is_not_a_draft_flag() {
// `app` is a conventional prefix, not a reserved one: the same spelling bound
Expand Down
4 changes: 2 additions & 2 deletions common/src/atompub/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ pub use title::{CollectionFeedTitle, CollectionTitle, WorkspaceTitle};

pub mod entry;
pub use entry::{
FeedMeta, MediaLinkEntry, entry_to_xml, is_draft, j_slug, render_feed, render_media_link_entry,
set_draft, set_j_slug,
FeedMeta, MediaLinkEntry, draft_marker, entry_to_xml, is_draft, j_slug, render_feed,
render_media_link_entry, set_draft, set_j_slug,
};

pub mod service;
Expand Down
43 changes: 42 additions & 1 deletion common/src/etag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
use std::str::FromStr;

use macros::StrNewtype;
use serde::Serialize;
use sha2::{Digest, Sha256};
use thiserror::Error;

use crate::media::ContentHash;
use crate::{
media::ContentHash, post_body::PostBody, post_summary::PostSummary, post_title::PostTitle,
render::PostFormat, tag::TagLabel,
};

/// A strong HTTP `ETag` — a double-quoted opaque validator (RFC 7232). The wrapped
/// `String` is private, so the only ways in are the validating [`FromStr`] (for stored /
Expand Down Expand Up @@ -129,6 +133,43 @@ impl ETag {
}
}

/// Computes the canonical strong validator for a Post's mutable content.
///
/// This deliberately serializes the historical `AtomPub` projection byte-for-byte:
/// field order, `format`'s display spelling, tag order, and the boolean draft
/// projection are part of the validator contract. Storage-specific ids and
/// timestamps are absent so an idempotent publish leaves the value unchanged.
#[must_use]
pub fn post_content_etag<'a>(
title: Option<&'a PostTitle>,
body: &'a PostBody,
format: &'a PostFormat,
summary: Option<&'a PostSummary>,
tags: impl IntoIterator<Item = &'a TagLabel>,
draft: bool,
) -> ETag {
#[derive(Serialize)]
struct Content<'a> {
title: Option<&'a PostTitle>,
body: &'a PostBody,
format: String,
summary: Option<&'a PostSummary>,
tags: Vec<&'a TagLabel>,
draft: bool,
}

let content = Content {
title,
body,
format: format.to_string(),
summary,
tags: tags.into_iter().collect(),
draft,
};
let bytes = serde_json::to_vec(&content).unwrap_or_else(|_| Vec::new());
ETag::sha256_of(bytes)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod local_storage_key;
pub mod mailbox;
pub mod mailer;
pub mod media;
pub mod org;
pub mod pagination;
pub mod password;
pub mod pg_identifier;
Expand Down
Loading