diff --git a/repub/web.py b/repub/web.py index 8b0187c..56c75b9 100644 --- a/repub/web.py +++ b/repub/web.py @@ -90,6 +90,9 @@ DEFAULT_PANGEA_CONTENT_FORMAT = "MOBILE_3" DEFAULT_PANGEA_CONTENT_TYPE = "articles" DEFAULT_PANGEA_MAX_ARTICLES = "10" DEFAULT_PANGEA_OLDEST_ARTICLE = "3" +STATIC_DIR = Path(__file__).resolve().parent / "static" +CACHE_BUSTED_STATIC_ASSETS = frozenset({"app.css"}) +CACHE_BUSTED_HASH_LENGTH = 12 def _render_shim_page( @@ -106,6 +109,24 @@ def _render_shim_page( return body, etag +def versioned_static_asset_filename(filename: str) -> str: + _require_cache_busted_static_asset(filename) + asset_path = STATIC_DIR / filename + truncated_hash = hashlib.sha256(asset_path.read_bytes()).hexdigest()[ + :CACHE_BUSTED_HASH_LENGTH + ] + return f"{asset_path.stem}-{truncated_hash}{asset_path.suffix}" + + +def versioned_static_asset_href(filename: str) -> str: + return f"/static/{versioned_static_asset_filename(filename)}" + + +def _require_cache_busted_static_asset(filename: str) -> None: + if filename not in CACHE_BUSTED_STATIC_ASSETS: + raise ValueError(f"Unsupported cache-busted static asset: {filename}") + + def create_app(*, dev_mode: bool = False) -> Quart: app = Quart(__name__) app.config["REPUB_DB_PATH"] = str(initialize_database()) @@ -127,6 +148,22 @@ def create_app(*, dev_mode: bool = False) -> Quart: response.mimetype = "application/rss+xml" return response + @app.get("/static/-.") + async def versioned_static_asset( + asset_name: str, asset_hash: str, extension: str + ) -> Response: + logical_filename = f"{asset_name}.{extension}" + requested_filename = f"{asset_name}-{asset_hash}.{extension}" + if logical_filename in CACHE_BUSTED_STATIC_ASSETS: + response = await send_from_directory(str(STATIC_DIR), logical_filename) + response.cache_control.public = True + response.cache_control.max_age = 31536000 + response.cache_control.immutable = True + return response + + response = await send_from_directory(str(STATIC_DIR), requested_filename) + return response + @app.get("/") @app.get("/sources") @app.get("/sources/create") @@ -141,7 +178,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: ) -> Response: del slug, job_id, execution_id body, etag = _render_shim_page( - stylesheet_href=url_for("static", filename="app.css"), + stylesheet_href=versioned_static_asset_href("app.css"), datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), current_path=request.path, ) diff --git a/tests/test_web.py b/tests/test_web.py index 82324e1..ad64b38 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -32,6 +32,7 @@ from repub.web import ( render_runs, render_settings, render_sources, + versioned_static_asset_href, ) @@ -177,10 +178,12 @@ def test_root_get_serves_datastar_shim() -> None: response = await client.get("/") body = await response.get_data(as_text=True) + stylesheet_href = versioned_static_asset_href("app.css") assert response.status_code == 200 assert response.headers["ETag"] assert body.startswith("") + assert f'' in body assert ( '' in body @@ -200,6 +203,43 @@ def test_root_get_serves_datastar_shim() -> None: asyncio.run(run()) +def test_versioned_static_asset_href_uses_truncated_file_hash() -> None: + href = versioned_static_asset_href("app.css") + + assert re.fullmatch(r"/static/app-[0-9a-f]{12}\.css", href) + + +def test_versioned_static_asset_route_serves_registered_css_file() -> None: + async def run() -> None: + client = create_app().test_client() + expected = ( + Path(__file__).resolve().parents[1] / "repub" / "static" / "app.css" + ).read_text(encoding="utf-8") + + response = await client.get("/static/app-deadbeefcafe.css") + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert response.mimetype == "text/css" + assert body == expected + + asyncio.run(run()) + + +def test_versioned_static_asset_route_preserves_existing_hyphenated_files() -> None: + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/static/datastar@1.0.0-RC.8.js") + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + assert response.mimetype == "text/javascript" + assert body.startswith("// Datastar v1.0.0-RC.8") + + asyncio.run(run()) + + def test_create_app_bootstraps_default_database_path( monkeypatch, tmp_path: Path ) -> None: