fix: reset stale webapp cache once per asset version
This commit is contained in:
@@ -20,6 +20,9 @@ _I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dic
|
|||||||
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
||||||
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
WEBAPP_CACHE_RESET_COOKIE_NAME = "rw_webapp_cache_v"
|
||||||
|
WEBAPP_CACHE_RESET_COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60
|
||||||
|
WEBAPP_CACHE_RESET_DIGEST_LENGTH = 16
|
||||||
|
|
||||||
|
|
||||||
async def health_route(request: web.Request) -> web.Response:
|
async def health_route(request: web.Request) -> web.Response:
|
||||||
@@ -1129,6 +1132,35 @@ def _apply_webapp_head_metadata(html_text: str, page_title: str, favicon_url: st
|
|||||||
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
|
return _replace_webapp_favicon(html_text, _favicon_head_markup(favicon_url))
|
||||||
|
|
||||||
|
|
||||||
|
def _webapp_cache_reset_version(css_asset_name: str, js_asset_name: str) -> str:
|
||||||
|
version = str(_resolve_app_version() or "dev+unknown").strip() or "dev+unknown"
|
||||||
|
raw = f"{version}|{css_asset_name}|{js_asset_name}"
|
||||||
|
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
return digest[:WEBAPP_CACHE_RESET_DIGEST_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_webapp_cache_reset_header(
|
||||||
|
request: web.Request,
|
||||||
|
response: web.StreamResponse,
|
||||||
|
cache_version: str,
|
||||||
|
) -> None:
|
||||||
|
"""Ask capable WebViews to clear stale HTTP cache once per deployed asset set."""
|
||||||
|
if not cache_version:
|
||||||
|
return
|
||||||
|
if request.cookies.get(WEBAPP_CACHE_RESET_COOKIE_NAME) == cache_version:
|
||||||
|
return
|
||||||
|
response.headers["Clear-Site-Data"] = '"cache"'
|
||||||
|
response.set_cookie(
|
||||||
|
WEBAPP_CACHE_RESET_COOKIE_NAME,
|
||||||
|
cache_version,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="None",
|
||||||
|
path="/",
|
||||||
|
max_age=WEBAPP_CACHE_RESET_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def index_route(request: web.Request) -> web.Response:
|
async def index_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
if not settings.WEBAPP_ENABLED:
|
if not settings.WEBAPP_ENABLED:
|
||||||
@@ -1142,9 +1174,11 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
bootstrap = _build_webapp_bootstrap_payload(request)
|
bootstrap = _build_webapp_bootstrap_payload(request)
|
||||||
config = bootstrap["config"]
|
config = bootstrap["config"]
|
||||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||||
|
css_asset_name = _resolve_webapp_css_asset_name()
|
||||||
|
js_asset_name = _resolve_webapp_js_asset_name()
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
'href="/subscription_webapp.css"',
|
'href="/subscription_webapp.css"',
|
||||||
f'href="/{_resolve_webapp_css_asset_name()}"',
|
f'href="/{css_asset_name}"',
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||||
@@ -1171,7 +1205,7 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
)
|
)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
WEBAPP_JS_PLACEHOLDER,
|
WEBAPP_JS_PLACEHOLDER,
|
||||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
f'<script src="/{js_asset_name}" defer></script>',
|
||||||
)
|
)
|
||||||
brand_asset_url = cached["logo_url"]
|
brand_asset_url = cached["logo_url"]
|
||||||
if brand_asset_url:
|
if brand_asset_url:
|
||||||
@@ -1187,6 +1221,11 @@ async def index_route(request: web.Request) -> web.Response:
|
|||||||
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
|
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Expires"] = "0"
|
response.headers["Expires"] = "0"
|
||||||
|
_apply_webapp_cache_reset_header(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
_webapp_cache_reset_version(css_asset_name, js_asset_name),
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -523,6 +523,52 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(exc.exception.location, "/uploaded-icon.png")
|
self.assertEqual(exc.exception.location, "/uploaded-icon.png")
|
||||||
self.assertEqual(exc.exception.headers["Cache-Control"], "no-cache")
|
self.assertEqual(exc.exception.headers["Cache-Control"], "no-cache")
|
||||||
|
|
||||||
|
def test_webapp_cache_reset_version_changes_with_asset_set(self):
|
||||||
|
with patch.object(webapp_assets, "_resolve_app_version", return_value="v1.2.3"):
|
||||||
|
first = webapp_assets._webapp_cache_reset_version(
|
||||||
|
"subscription_webapp.11111111.css",
|
||||||
|
"subscription_webapp.min.22222222.js",
|
||||||
|
)
|
||||||
|
second = webapp_assets._webapp_cache_reset_version(
|
||||||
|
"subscription_webapp.33333333.css",
|
||||||
|
"subscription_webapp.min.22222222.js",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRegex(first, r"^[0-9a-f]{16}$")
|
||||||
|
self.assertNotEqual(first, second)
|
||||||
|
|
||||||
|
def test_webapp_cache_reset_header_is_sent_once_per_version(self):
|
||||||
|
request = SimpleNamespace(cookies={})
|
||||||
|
response = web.Response()
|
||||||
|
|
||||||
|
webapp_assets._apply_webapp_cache_reset_header(request, response, "abc123")
|
||||||
|
|
||||||
|
self.assertEqual(response.headers["Clear-Site-Data"], '"cache"')
|
||||||
|
cookie = response.cookies[webapp_assets.WEBAPP_CACHE_RESET_COOKIE_NAME]
|
||||||
|
self.assertEqual(cookie.value, "abc123")
|
||||||
|
self.assertEqual(cookie["path"], "/")
|
||||||
|
self.assertEqual(
|
||||||
|
cookie["max-age"],
|
||||||
|
str(webapp_assets.WEBAPP_CACHE_RESET_COOKIE_MAX_AGE_SECONDS),
|
||||||
|
)
|
||||||
|
self.assertTrue(cookie["httponly"])
|
||||||
|
self.assertTrue(cookie["secure"])
|
||||||
|
self.assertEqual(cookie["samesite"], "None")
|
||||||
|
|
||||||
|
cached_request = SimpleNamespace(
|
||||||
|
cookies={webapp_assets.WEBAPP_CACHE_RESET_COOKIE_NAME: "abc123"}
|
||||||
|
)
|
||||||
|
cached_response = web.Response()
|
||||||
|
|
||||||
|
webapp_assets._apply_webapp_cache_reset_header(
|
||||||
|
cached_request,
|
||||||
|
cached_response,
|
||||||
|
"abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotIn("Clear-Site-Data", cached_response.headers)
|
||||||
|
self.assertNotIn(webapp_assets.WEBAPP_CACHE_RESET_COOKIE_NAME, cached_response.cookies)
|
||||||
|
|
||||||
async def test_default_logo_route_serves_bundled_logo(self):
|
async def test_default_logo_route_serves_bundled_logo(self):
|
||||||
settings = SimpleNamespace(WEBAPP_ENABLED=True)
|
settings = SimpleNamespace(WEBAPP_ENABLED=True)
|
||||||
request = SimpleNamespace(app={"settings": settings})
|
request = SimpleNamespace(app={"settings": settings})
|
||||||
|
|||||||
Reference in New Issue
Block a user