diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 5121369..6d18e1f 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -363,6 +363,24 @@ Tests (`test_functional.py::TestProductTagsSpa`): Deferred (occasional click, not the hot path): AJAX-ifying the "Suggest categories" link — still a full navigation by design. +**Phase 2.8p — Checksums as a verifiable page report** (shipped +2026-05-17): operator wants the content page's "Checksums" panel to +cover the whole page, not just the product file — +product/content file + thumbnail1 + title + description, so a human +or agent can re-hash what they see and confirm provenance. The async +checksum infra (`lib/checksums.py` / `compute_checksums_async`) is +already generic — the upload pipeline (`views/product.py:692`) +computes `checksums[file_key]` for ANY uploaded key incl. +`thumbnail1`, recomputed on re-upload (no separate thumbnail +auto-gen pipeline exists). Added `Product.content_checksums()` — +**live** SHA-256+MD5 of `title`+`description` (computed on read, not +stored, so it always matches the visible text). `content.j2` +Checksums `
` is now a 4-asset report table (Asset / +Algorithm / Hash), SHA-256 + MD5 per asset. Tests: +`TestContentChecksums` (3, no-DB). Decisions: stored+recompute-on- +change for thumbnail (already satisfied by the generic upload path), +SHA-256 + MD5 both shown (match existing). + **Phase 2.8o — manual tags are ghost metadata: hide behind a flag** (shipped 2026-05-17): operator direction — stop hand-attaching tags ("ghost metadata" invisible to humans/agents reading the page); diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 6dd2130..c6182de 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -493,6 +493,28 @@ class Product(RBase, Base): def checksums(self): return self.file_metadata.get("checksums", {}) + def content_checksums(self): + """MPS-24: live SHA-256 + MD5 of the page's TEXT content (title, + description). Computed on read — not stored — so an agent or + human can re-hash exactly the bytes they see and verify them. + Combined with the stored file + thumbnail1 checksums + (`self.checksums`), the Checksums panel becomes a verifiable + provenance report about the whole page. + """ + import hashlib + + out = {} + for key, text in ( + ("title", self.title or ""), + ("description", self.description or ""), + ): + data = text.encode("utf-8") + out[key] = { + "sha256": hashlib.sha256(data).hexdigest(), + "md5": hashlib.md5(data).hexdigest(), + } + return out + def set_checksum(self, file_key, md5_hex, sha256_hex): tmp = self.file_metadata if "checksums" not in tmp: diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index 6df63c9..ddb5fdd 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -140,16 +140,28 @@ {% endif %} - {% set cs = product.checksums.get("product", {}) %} - {% if cs.get("sha256") or cs.get("md5") %} + {# MPS-24: Checksums as a verifiable page report — the product/ + content file + thumbnail1 (stored, computed in the background on + (re)upload) and the title + description (hashed live so it always + matches the visible text). Lets a human or agent re-hash what + they see and confirm provenance. #} + {% set fc = product.checksums %} + {% set tc = product.content_checksums() %}
- Checksums + Checksums — page report - {% if cs.get("sha256") %}{% endif %} - {% if cs.get("md5") %}{% endif %} + + {% for label, src in [ + ("Product file", fc.get("product", {})), + ("Thumbnail", fc.get("thumbnail1", {})), + ("Title", tc.get("title", {})), + ("Description", tc.get("description", {})), + ] %} + {% if src.get("sha256") %}{% endif %} + {% if src.get("md5") %}{% endif %} + {% endfor %}
SHA-256{{ cs["sha256"] }}
MD5{{ cs["md5"] }}
AssetAlgorithmHash
{{ label }}SHA-256{{ src["sha256"] }}
{% if not src.get("sha256") %}{{ label }}{% endif %}MD5{{ src["md5"] }}
- {% endif %}
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index f3a432e..73984d1 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -5132,6 +5132,45 @@ class TestShopHomeLayout(unittest.TestCase): self.assertEqual(shop.tag_stopwords, []) +class TestContentChecksums(unittest.TestCase): + """MPS-24: Product.content_checksums() — live SHA-256+MD5 of the + page's title + description so the Checksums panel is a verifiable + report (no DB).""" + + def test_matches_hashlib_for_title_and_description(self): + import hashlib + from ..models.product import Product + p = Product(title="Winter Holiday Pack", + description="Great first grade practice.") + cs = p.content_checksums() + for key, text in (("title", "Winter Holiday Pack"), + ("description", "Great first grade practice.")): + data = text.encode("utf-8") + self.assertEqual( + cs[key]["sha256"], hashlib.sha256(data).hexdigest() + ) + self.assertEqual( + cs[key]["md5"], hashlib.md5(data).hexdigest() + ) + + def test_handles_empty_title_description(self): + import hashlib + from ..models.product import Product + p = Product(title="", description="") + empty = hashlib.sha256(b"").hexdigest() + cs = p.content_checksums() + self.assertEqual(cs["title"]["sha256"], empty) + self.assertEqual(cs["description"]["sha256"], empty) + + def test_changes_when_text_changes(self): + from ..models.product import Product + p = Product(title="Alpha", description="x") + before = p.content_checksums()["title"]["sha256"] + p.title = "Beta" + after = p.content_checksums()["title"]["sha256"] + self.assertNotEqual(before, after) + + class TestTagModel(unittest.TestCase): """MPS-24: Tag model construction + slugification (no DB)."""