diff --git a/make_post_sell/lib/checksums.py b/make_post_sell/lib/checksums.py
new file mode 100644
index 0000000..247320f
--- /dev/null
+++ b/make_post_sell/lib/checksums.py
@@ -0,0 +1,90 @@
+"""checksums.py — background MD5 + SHA-256 computation for uploaded files.
+
+After a file lands in its final S3 location, compute both checksums by streaming
+the object through Python's hashlib. Results are stored in the product's
+file_metadata under {"checksums": {file_key: {"md5": "...", "sha256": "..."}}}.
+
+Fire-and-forget daemon thread — same pattern as torrent.py and s3_mirror.py.
+"""
+
+import hashlib
+import logging
+import threading
+
+import boto3
+
+log = logging.getLogger(__name__)
+
+
+def _capture_s3_creds(client):
+ creds = client._request_signer._credentials
+ meta = client.meta
+ return {
+ "endpoint": meta.endpoint_url,
+ "region": meta.region_name,
+ "access_key": creds.access_key,
+ "secret_key": creds.secret_key,
+ }
+
+
+def _make_client(creds):
+ return boto3.session.Session().client(
+ "s3",
+ region_name=creds["region"],
+ endpoint_url=creds["endpoint"],
+ aws_access_key_id=creds["access_key"],
+ aws_secret_access_key=creds["secret_key"],
+ )
+
+
+def compute_checksums(s3_client, bucket, s3_key, file_key, product_id, session_factory):
+ """Stream s3_key, compute MD5 + SHA-256, save to product file_metadata.
+
+ Designed to run in a background thread.
+ """
+ log.info("checksums: computing for product=%s key=%s", product_id, s3_key)
+
+ md5 = hashlib.md5()
+ sha256 = hashlib.sha256()
+
+ try:
+ response = s3_client.get_object(Bucket=bucket, Key=s3_key)
+ for chunk in response["Body"].iter_chunks(chunk_size=1024 * 1024):
+ md5.update(chunk)
+ sha256.update(chunk)
+ except Exception:
+ log.exception("checksums: failed to stream s3://%s/%s", bucket, s3_key)
+ return
+
+ md5_hex = md5.hexdigest()
+ sha256_hex = sha256.hexdigest()
+ log.info("checksums: product=%s key=%s md5=%s sha256=%s",
+ product_id, s3_key, md5_hex, sha256_hex[:16] + "...")
+
+ try:
+ with session_factory() as session:
+ from ..models.product import Product
+ product = session.get(Product, product_id)
+ if product is not None:
+ product.set_checksum(file_key, md5_hex, sha256_hex)
+ session.add(product)
+ session.commit()
+ log.info("checksums: saved for product=%s file_key=%s", product_id, file_key)
+ except Exception:
+ log.exception("checksums: failed to save for product=%s", product_id)
+
+
+def compute_checksums_async(s3_client, bucket, s3_key, file_key, product_id, session_factory):
+ """Fire-and-forget: compute checksums in a daemon thread."""
+ creds = _capture_s3_creds(s3_client)
+
+ def _run():
+ try:
+ fresh_client = _make_client(creds)
+ compute_checksums(fresh_client, bucket, s3_key, file_key, product_id, session_factory)
+ except Exception:
+ log.exception("checksums: background thread failed for product=%s", product_id)
+
+ t = threading.Thread(target=_run, daemon=True, name=f"checksums-{product_id}-{file_key}")
+ t.start()
+ log.info("checksums: background thread started for product=%s file_key=%s", product_id, file_key)
diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py
index 99975b6..2f126e4 100644
--- a/make_post_sell/models/product.py
+++ b/make_post_sell/models/product.py
@@ -385,6 +385,18 @@ class Product(RBase, Base):
def originals(self):
return self.file_metadata.get("originals", {})
+ @property
+ def checksums(self):
+ return self.file_metadata.get("checksums", {})
+
+ def set_checksum(self, file_key, md5_hex, sha256_hex):
+ tmp = self.file_metadata
+ if "checksums" not in tmp:
+ tmp["checksums"] = {}
+ tmp["checksums"][file_key] = {"md5": md5_hex, "sha256": sha256_hex}
+ self._file_metadata = tmp
+ self.json_file_metadata = json.dumps(tmp)
+
def set_file_metadata(self, file_key, extension, original_filename):
tmp = self.file_metadata
tmp["extensions"][file_key] = extension
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 5a1d765..1b8da08 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -230,3 +230,7 @@ def includeme(config):
# Torrent backfill progress
config.add_route("shop_torrent_backfill_status", "/s/{shop_id}/torrent-backfill-status")
+
+ # Checksum backfill
+ config.add_route("shop_checksum_backfill", "/s/{shop_id}/checksum-backfill")
+ config.add_route("shop_checksum_backfill_status", "/s/{shop_id}/checksum-backfill-status")
diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2
index db46b6c..e6aee94 100644
--- a/make_post_sell/templates/content.j2
+++ b/make_post_sell/templates/content.j2
@@ -138,6 +138,17 @@
Created {{ product.human_created_timestamp }}{% if product.human_created_timestamp != product.human_updated_timestamp %} · Updated {{ product.human_updated_timestamp }}{% endif %}{% if product.human_view_count %} · {{ product.human_view_count }}{% endif %}
{% endif %}
+
+ {% set cs = product.checksums.get("product", {}) %}
+ {% if cs.get("sha256") or cs.get("md5") %}
+ Checksums
+
+ {% if cs.get("sha256") %}
+ {% endif %}
+ {% if cs.get("md5") %}SHA-256 {{ cs["sha256"] }} {% endif %}
+ MD5 {{ cs["md5"] }}
File Checksums
+MD5 and SHA-256 checksums are computed for every uploaded file and displayed on content pages so visitors can verify integrity.
+ +Backfill progress
+Loading…
+