diff --git a/make_post_sell/lib/cache_version.py b/make_post_sell/lib/cache_version.py new file mode 100644 index 0000000..8f186c5 --- /dev/null +++ b/make_post_sell/lib/cache_version.py @@ -0,0 +1,27 @@ +import hashlib + +from ..views.version import GIT_HASH + + +def compute_cache_version(shop): + """Opaque token that changes when deploys land or the ring is reforged. + + Clients compare this to their localStorage copy; mismatch means + cached ring state (ringProductIds, ringPosition, ringHistory) is + stale and should be dropped before the next SPA navigation. + + Inputs: + GIT_HASH — shifts on every deploy + shop.json_discovery_ring — shifts on every reforge (including + product adds/removes/visibility flips that trigger reforge) + """ + ring_str = (shop.json_discovery_ring or "") if shop is not None else "" + digest = hashlib.md5(f"{GIT_HASH}:{ring_str}".encode()).hexdigest() + return digest[:12] + + +def apply_no_store_headers(response): + """Tell browsers not to cache this HTML response — always revalidate.""" + response.headers["Cache-Control"] = "no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index a8b21a4..5754c29 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -38,6 +38,31 @@ var karaokeEligible = false; var karaokeProcessing = false; + // --- Cache version: flushes stale ring state on deploy / reforge --- + // Server stamps every page (meta[name="mps-cache-version"]) and every + // watch_json response. Stored client version is compared on each + // page load and SPA nav; mismatch drops cached ringProductIds, + // ringPosition, ringHistory, and ringLoops before anything reads them. + function enforceCacheVersion(serverVersion) { + if (!serverVersion) return; + var stored = null; + try { stored = localStorage.getItem('watchCacheVersion'); } catch (e) {} + if (stored && stored !== serverVersion) { + try { + localStorage.removeItem('watchRing'); + localStorage.removeItem('watchRingPosition'); + localStorage.removeItem('watchRingHistory'); + localStorage.removeItem('watchRingLoops'); + localStorage.removeItem('watchQueue'); + } catch (e) {} + } + try { localStorage.setItem('watchCacheVersion', serverVersion); } catch (e) {} + } + (function initialCacheCheck() { + var meta = document.querySelector('meta[name="mps-cache-version"]'); + if (meta) enforceCacheVersion(meta.getAttribute('content')); + })(); + // --- Ring state (persisted in localStorage) --- var ringProductIds = []; var ringPosition = 0; @@ -745,6 +770,10 @@ // --- Update page content (title, description, related, URL) --- function updatePageContent(data) { + // Server may have deployed or reforged since last nav — drop + // stale ring state before applying new data. + if (data.cache_version) enforceCacheVersion(data.cache_version); + // Update ring and mod status from server response if (data.ring && data.ring.length) { ringProductIds = data.ring; diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index eb9c21e..12abe63 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -63,6 +63,9 @@ + {% if cache_version %} + + {% endif %} {% if request.is_saas_domain == false and request.shop and request.shop.favicon %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 44668c8..0e63fb1 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -3155,6 +3155,102 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase): self.assertFalse(data["valid"]) self.assertIn(stale_id, data["stale"]) + def test_content_page_sets_no_store_cache_header(self): + """Content page HTML must not be cached by the browser.""" + import json as _json + shop = self._create_shop_helper( + shop_params={**self.shop1_params, "name": "cache-header-content-shop"} + ) + shop_id = str(shop.id) + self.testapp.post( + f"/s/{shop_id}/settings", + {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"}, + status=302, + ) + self.testapp.post( + "/c/new", + {"title": "Cache Header Test", "description": "tests cache header", "submit": True}, + ) + + products = get_all_products(self.dbsession).all() + product = products[0] + product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}} + product.json_file_metadata = _json.dumps(product._file_metadata) + product.visibility = 1 + self.dbsession.flush() + product_id = str(product.id) + product_slug = product.slug + transaction.commit() + + res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200) + self.assertIn("no-store", res.headers.get("Cache-Control", "")) + # meta tag with cache version present + self.assertIn('name="mps-cache-version"', res.body.decode()) + + def test_watch_json_includes_cache_version(self): + """watch_json response carries cache_version for client compare.""" + import json as _json + shop = self._create_shop_helper( + shop_params={**self.shop1_params, "name": "cache-header-watch-shop"} + ) + shop_id = str(shop.id) + self.testapp.post( + f"/s/{shop_id}/settings", + {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"}, + status=302, + ) + self.testapp.post( + "/c/new", + {"title": "Watch JSON Cache", "description": "desc", "submit": True}, + ) + products = get_all_products(self.dbsession).all() + product = products[0] + product._file_metadata = {"product": {"extension": "mp4", "content_type": "video/mp4"}} + product.json_file_metadata = _json.dumps(product._file_metadata) + product.visibility = 1 + self.dbsession.flush() + product_id = str(product.id) + transaction.commit() + + # Product has no uploaded media → 404, but cache_version still + # comes back on the error response so clients can flush state. + res = self.testapp.get(f"/watch/{product_id}/json", status=404) + self.assertIn("cache_version", res.json) + self.assertTrue(len(res.json["cache_version"]) > 0) + + def test_cache_version_changes_after_reforge(self): + """Reforging the ring changes cache_version — clients will flush.""" + shop = self._create_shop_helper( + shop_params={**self.shop1_params, "name": "cache-version-change-shop"} + ) + shop_id = str(shop.id) + self.testapp.post( + f"/s/{shop_id}/settings", + {"form_section": "ribbon-settings", "watch_mode": "1", "submit": "Save Settings"}, + status=302, + ) + self.testapp.post( + f"/p/new?shop_id={shop_id}", self.product1_params + ).follow() + + from ..models.shop import reforge_discovery_ring + from ..lib.cache_version import compute_cache_version + self.dbsession.expire(shop) + reforge_discovery_ring(shop) + self.dbsession.flush() + v1 = compute_cache_version(shop) + + # Add a second product — next reforge will produce a different ring + self.testapp.post( + f"/p/new?shop_id={shop_id}", + {**self.product1_params, "title": "second product for cache version"}, + ).follow() + reforge_discovery_ring(shop) + self.dbsession.flush() + v2 = compute_cache_version(shop) + + self.assertNotEqual(v1, v2) + def test_ring_health_shop_not_found(self): """Unknown shop_id returns 404.""" self._create_shop_helper( diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py index 64c7246..5453184 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -2688,6 +2688,63 @@ class TestValidateDiscoveryRing(unittest.TestCase): self.assertEqual(health["public_count"], 2) +class TestCacheVersion(unittest.TestCase): + """Test the cache_version helper used to invalidate client ring state.""" + + def _make_shop(self, ring=None): + shop = Shop("cv-shop", "555-555-5555", "123 CV St", "desc") + if ring is not None: + shop.discovery_ring = ring + return shop + + def test_stable_for_unchanged_inputs(self): + """Same GIT_HASH + same ring → same cache_version.""" + from ..lib.cache_version import compute_cache_version + shop = self._make_shop(ring=["a", "b", "c"]) + self.assertEqual( + compute_cache_version(shop), + compute_cache_version(shop), + ) + + def test_changes_when_ring_content_changes(self): + """Any change to the ring list shifts the cache_version.""" + from ..lib.cache_version import compute_cache_version + v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"])) + v2 = compute_cache_version(self._make_shop(ring=["a", "b", "d"])) + self.assertNotEqual(v1, v2) + + def test_changes_when_ring_order_changes(self): + """Reordering the ring (reforge) shifts the cache_version.""" + from ..lib.cache_version import compute_cache_version + v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"])) + v2 = compute_cache_version(self._make_shop(ring=["c", "b", "a"])) + self.assertNotEqual(v1, v2) + + def test_none_shop_returns_token(self): + """Passing None shop does not crash — returns a stable token.""" + from ..lib.cache_version import compute_cache_version + v = compute_cache_version(None) + self.assertIsInstance(v, str) + self.assertTrue(len(v) > 0) + + def test_empty_ring_is_not_error(self): + """Shop with no ring still gets a cache_version.""" + from ..lib.cache_version import compute_cache_version + v = compute_cache_version(self._make_shop(ring=[])) + self.assertIsInstance(v, str) + self.assertTrue(len(v) > 0) + + def test_changes_with_git_hash(self): + """GIT_HASH flip changes cache_version (simulated via patch).""" + from ..lib import cache_version as cv_module + shop = self._make_shop(ring=["a", "b"]) + with mock.patch.object(cv_module, "GIT_HASH", "hash-one"): + v1 = cv_module.compute_cache_version(shop) + with mock.patch.object(cv_module, "GIT_HASH", "hash-two"): + v2 = cv_module.compute_cache_version(shop) + self.assertNotEqual(v1, v2) + + class TestAsyncDiscoveryRing(unittest.TestCase): """Test the async ring reforge with dirty bit debounce.""" diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index d64d129..6ca6897 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -2,6 +2,7 @@ from pyramid.view import view_config from . import get_referer_or_home from ..models.product import get_media_type +from ..lib.cache_version import compute_cache_version, apply_no_store_headers from pyramid.httpexceptions import HTTPFound @@ -82,6 +83,11 @@ def content(request): else: related_products = get_related_products(product) + # Dynamic content page — never cache HTML at the browser. Sidebar + # (Up Next) reflects live ring state; stale HTML produces "pocket" + # confusion where the sidebar disagrees with reality. + apply_no_store_headers(request.response) + # Generate CDN URLs for karaoke tracks if available instrumentals_url = None vocals_url = None @@ -107,6 +113,7 @@ def content(request): "comments": comments, "shop": product.shop, "related_products": related_products, + "cache_version": compute_cache_version(product.shop), "instrumentals_url": instrumentals_url, "vocals_url": vocals_url, "karaoke_eligible": karaoke_eligible, diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 8bac053..f2b7310 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -16,6 +16,7 @@ from ..models.inventory import Inventory from ..lib.currency import validate_float, cents_to_dollars from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime +from ..lib.cache_version import compute_cache_version, apply_no_store_headers def checkbox_to_bool(checkbox): @@ -118,6 +119,10 @@ def product(request): "is_current": i == 0, }) + # Dynamic product page — never cache HTML at the browser. Same + # reason as content.py: Up Next sidebar must reflect live ring state. + apply_no_store_headers(request.response) + return { "product": product, "product_size": product_size, @@ -125,6 +130,7 @@ def product(request): "comments": comments, "shop": product.shop, "related_products": related_products, + "cache_version": compute_cache_version(product.shop), "price_history": price_history, } diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py index dab34ba..f2e3f43 100644 --- a/make_post_sell/views/watch.py +++ b/make_post_sell/views/watch.py @@ -4,6 +4,7 @@ from pyramid.view import view_config from ..models.product import get_media_type, get_related_products, get_ring_related_products from ..models.shop import get_shop_by_id, validate_discovery_ring +from ..lib.cache_version import compute_cache_version from ..lib.currency import cents_to_dollars from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime @@ -16,30 +17,36 @@ def watch_json(request): product = request.product + # Compute cache version up front so every response (including errors) + # carries it — lets the client flush stale localStorage ring state + # even when the current product can't be played. + shop_for_cache = request.shop if request.shop else (product.shop if product else None) + cache_version = compute_cache_version(shop_for_cache) + if not product: request.response.status_int = 404 - return {"error": "Product not found"} + return {"error": "Product not found", "cache_version": cache_version} if not request.shop or not request.shop.watch_mode_enabled: request.response.status_int = 404 - return {"error": "Watch mode not enabled"} + return {"error": "Watch mode not enabled", "cache_version": cache_version} # Only allow public products (visibility == 1) if product.visibility != 1: request.response.status_int = 403 - return {"error": "Not publicly available"} + return {"error": "Not publicly available", "cache_version": cache_version} # Determine which file to use if product.is_sellable: file_key = "preview" if file_key not in product.extensions: request.response.status_int = 403 - return {"error": "No preview available"} + return {"error": "No preview available", "cache_version": cache_version} else: file_key = "product" if file_key not in product.extensions: request.response.status_int = 404 - return {"error": "No media file available"} + return {"error": "No media file available", "cache_version": cache_version} # Get extension and detect media type extension = product.extensions.get(file_key) @@ -204,6 +211,7 @@ def watch_json(request): }) result = { + "cache_version": compute_cache_version(request.shop), "product_id": str(product.id), "shop_id": str(product.shop_id), "title": product.title,