Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions photomap/backend/photomap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware

Expand All @@ -31,6 +30,7 @@
from photomap.backend.routers.search import search_router
from photomap.backend.routers.umap import umap_router
from photomap.backend.routers.upgrade import upgrade_router
from photomap.backend.static_assets import VersionedStaticFiles, compute_asset_version
from photomap.backend.util import get_app_url

# Initialize logging
Expand Down Expand Up @@ -89,13 +89,27 @@ async def dispatch(self, request: Request, call_next):

app.add_middleware(IECompatibilityMiddleware)

# Mount static files and templates
# Mount static files and templates.
#
# Assets are served under a version-stamped path (static/<asset_version>/...) so
# browsers — iOS Safari in particular — can't serve a stale module or stylesheet
# after an upgrade. The version is a content hash, so the URL only changes when
# the assets do. See photomap.backend.static_assets for the full rationale.
static_path = get_package_resource_path("static")
app.mount("/static", StaticFiles(directory=static_path), name="static")
asset_version = compute_asset_version(static_path, get_version())
app.mount(
"/static",
VersionedStaticFiles(directory=static_path, version=asset_version),
name="static",
)

templates_path = get_package_resource_path("templates")
templates = Jinja2Templates(directory=templates_path)

# Expose a `static_url('css/base.css')` helper to every template so asset
# references pick up the cache-busting version segment automatically.
templates.env.globals["static_url"] = lambda path: f"static/{asset_version}/{path}"


# Main Routes
@app.get("/", response_class=HTMLResponse, tags=["Main"])
Expand Down
89 changes: 89 additions & 0 deletions photomap/backend/static_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Cache-busting for the no-build frontend.

PhotoMapAI serves its ES6 modules and CSS straight off disk with no bundler, so
a browser (iOS Safari especially) can hold on to a stale ``swiper.js`` or
stylesheet long after it changed — which once looked like a phantom regression.

The fix is *path-based* versioning: assets are referenced as
``static/<version>/css/base.css`` instead of ``static/css/base.css``. A query
string (``?v=...``) would not work here because the relative ``import`` URLs
inside a module resolve against the module's own URL with the query stripped, so
only the entry point would be busted. A version *path segment* is preserved
through relative resolution: ``main.js`` loaded from ``/static/<v>/main.js``
imports ``./javascript/state.js`` as ``/static/<v>/javascript/state.js``, so the
whole module graph (and ``@import``-free CSS) inherits the version automatically.

``VersionedStaticFiles`` strips the leading ``<version>`` segment back off so the
file is still found on disk, and stamps versioned responses as immutable so the
browser may cache them forever — a new release changes the version, hence the
URL, so there is nothing stale to serve. Unversioned ``/static/...`` requests
still work unchanged (e.g. the hardcoded ``unsupported-browser.html`` fallback).
"""

from __future__ import annotations

import hashlib
import re
from pathlib import Path

from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import Scope

# Files whose contents define the asset fingerprint. Limiting to the text assets
# that are actually served to the page keeps startup hashing cheap and stable.
_HASHED_SUFFIXES = {".js", ".css", ".html", ".webmanifest"}


def compute_asset_version(static_dir: str | Path, app_version: str) -> str:
"""Return a stable cache-busting token for the current static assets.

The token folds in the package version and a content hash of the served
text assets, so it changes whenever any module/stylesheet changes (busting
caches) but is identical across restarts and deploys of the same code
(keeping caches warm). Pure content hashing — not mtimes — so reinstalling
the same release does not needlessly invalidate client caches.
"""
static_dir = Path(static_dir)
hasher = hashlib.sha1()
hasher.update(app_version.encode("utf-8"))
for path in sorted(static_dir.rglob("*")):
if not path.is_file() or path.suffix.lower() not in _HASHED_SUFFIXES:
continue
hasher.update(path.relative_to(static_dir).as_posix().encode("utf-8"))
hasher.update(b"\0")
hasher.update(path.read_bytes())
# Keep the token to URL-path-safe characters: dev versions from
# importlib.metadata can carry '+'/local-version segments (e.g.
# "1.0.7.dev3+g1234") that read awkwardly inside a path.
safe_version = re.sub(r"[^A-Za-z0-9.-]", "-", app_version)
return f"v{safe_version}.{hasher.hexdigest()[:10]}"


class VersionedStaticFiles(StaticFiles):
"""``StaticFiles`` that accepts (and strips) a leading ``<version>`` segment.

``/static/<version>/css/base.css`` is served from ``css/base.css`` on disk
and marked immutable; plain ``/static/css/base.css`` is served as usual.
"""

def __init__(self, *args, version: str, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._version_prefix = f"{version}/"

async def get_response(self, path: str, scope: Scope) -> Response:
# StaticFiles.get_path normalises to OS-specific separators, so on
# Windows ``path`` arrives with backslashes (e.g. ``v1.2.3\\main.js``).
# Compare on forward slashes so the version segment is recognised on
# every platform; the forward-slash remainder we hand back to
# ``super().get_response`` resolves correctly on Windows too.
normalized = path.replace("\\", "/")
versioned = normalized.startswith(self._version_prefix)
if versioned:
path = normalized[len(self._version_prefix) :]
response = await super().get_response(path, scope)
if versioned and response.status_code == 200:
# The version segment uniquely identifies this content, so it can be
# cached indefinitely; a new release changes the URL.
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
42 changes: 21 additions & 21 deletions photomap/frontend/templates/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico" />
<link rel="icon" type="image/x-icon" href="{{ static_url('icons/favicon.ico') }}" />

<!-- Detect legacy EdgeHTML (Edge ≤18 / Edge 44) before loading any ES2020 scripts.
window.StyleMedia is unique to EdgeHTML and absent from Chromium-based Edge.
Expand All @@ -27,25 +27,25 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
<link rel="stylesheet" type="text/css" href="static/css/base.css" />
<link rel="stylesheet" type="text/css" href="static/css/animations.css" />
<link rel="stylesheet" type="text/css" href="static/css/modal-base.css" />
<link rel="stylesheet" type="text/css" href="static/css/spinners.css" />
<link rel="stylesheet" type="text/css" href="static/css/toasts.css" />
<link rel="stylesheet" type="text/css" href="static/css/swiper.css" />
<link rel="stylesheet" type="text/css" href="static/css/grid-view.css" />
<link rel="stylesheet" type="text/css" href="static/css/control-and-search-panels.css" />
<link rel="stylesheet" type="text/css" href="static/css/seek-slider.css" />
<link rel="stylesheet" type="text/css" href="static/css/metadata-drawer.css" />
<link rel="stylesheet" type="text/css" href="static/css/search.css" />
<link rel="stylesheet" type="text/css" href="static/css/settings.css" />
<link rel="stylesheet" type="text/css" href="static/css/album-manager.css" />
<link rel="stylesheet" type="text/css" href="static/css/filetree.css" />
<link rel="stylesheet" type="text/css" href="static/css/delete-modal.css" />
<link rel="stylesheet" type="text/css" href="static/css/about.css" />
<link rel="stylesheet" type="text/css" href="static/css/bookmarks.css" />
<link rel="stylesheet" type="text/css" href="static/css/umap-floating-window.css" />
<link rel="stylesheet" type="text/css" href="static/css/curation.css" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/base.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/animations.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/modal-base.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/spinners.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/toasts.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/swiper.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/grid-view.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/control-and-search-panels.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/seek-slider.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/metadata-drawer.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/search.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/settings.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/album-manager.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/filetree.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/delete-modal.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/about.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/bookmarks.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/umap-floating-window.css') }}" />
<link rel="stylesheet" type="text/css" href="{{ static_url('css/curation.css') }}" />

<script>
window.slideshowConfig = {
Expand All @@ -56,7 +56,7 @@
albumLocked: {% if album_locked %}true{% else %}false{% endif %}
};
</script>
<script type="module" src="static/main.js"></script>
<script type="module" src="{{ static_url('main.js') }}"></script>
</head>

<body>
Expand Down
107 changes: 107 additions & 0 deletions tests/backend/test_static_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Tests for cache-busting of the no-build frontend assets."""
import asyncio
import re

from photomap.backend.constants import get_package_resource_path
from photomap.backend.static_assets import VersionedStaticFiles, compute_asset_version


def test_compute_asset_version_is_stable_for_unchanged_content(tmp_path):
(tmp_path / "app.js").write_text("console.log('hi');")
(tmp_path / "style.css").write_text("body { color: red; }")

first = compute_asset_version(tmp_path, "1.2.3")
second = compute_asset_version(tmp_path, "1.2.3")

assert first == second
assert first.startswith("v1.2.3.")


def test_compute_asset_version_changes_when_content_changes(tmp_path):
js = tmp_path / "app.js"
js.write_text("console.log('hi');")
before = compute_asset_version(tmp_path, "1.2.3")

js.write_text("console.log('changed');")
after = compute_asset_version(tmp_path, "1.2.3")

assert before != after


def test_compute_asset_version_changes_with_app_version(tmp_path):
(tmp_path / "app.js").write_text("console.log('hi');")

assert compute_asset_version(tmp_path, "1.2.3") != compute_asset_version(tmp_path, "1.2.4")


def test_compute_asset_version_ignores_binary_assets(tmp_path):
"""Non-text assets (icons) don't perturb the fingerprint."""
(tmp_path / "app.js").write_text("console.log('hi');")
before = compute_asset_version(tmp_path, "1.2.3")

(tmp_path / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n_fake_image_data")
after = compute_asset_version(tmp_path, "1.2.3")

assert before == after


def _asset_version_from_home(client) -> str:
"""Pull the live asset-version token out of the rendered main page."""
body = client.get("/").text
match = re.search(r"static/(v[^/\"']+)/main\.js", body)
assert match, "main page should reference a version-stamped main.js"
return match.group(1)


def test_home_page_references_versioned_assets(client):
body = client.get("/").text
# The cache-busting prefix is present...
assert re.search(r"static/v[^/\"']+/css/base\.css", body)
# ...and the old unversioned reference is gone.
assert 'href="static/css/base.css"' not in body


def test_versioned_asset_is_served_and_marked_immutable(client):
version = _asset_version_from_home(client)

resp = client.get(f"/static/{version}/main.js")
assert resp.status_code == 200
# Confirm the real module was served. The content-type for .js varies by OS
# / registry (notably on Windows), so assert on the body, not the MIME type.
assert "import" in resp.text
assert "immutable" in resp.headers.get("cache-control", "")

# A nested module import resolves under the same version segment.
resp_css = client.get(f"/static/{version}/css/base.css")
assert resp_css.status_code == 200
assert "immutable" in resp_css.headers.get("cache-control", "")


def test_unversioned_asset_still_served_without_immutable_cache(client):
# The hardcoded unsupported-browser fallback relies on the plain path.
resp = client.get("/static/css/base.css")
assert resp.status_code == 200
assert "immutable" not in resp.headers.get("cache-control", "")


def test_wrong_version_segment_is_not_served(client):
# A stale/foreign version segment must not resolve to a real file.
resp = client.get("/static/vDOESNOTMATCH.0000000000/main.js")
assert resp.status_code == 404


def test_os_specific_separator_in_path_is_handled(client):
"""StaticFiles.get_path hands get_response an OS-specific path: backslashes
on Windows. The version prefix must still be recognised, or every versioned
asset 404s on Windows. Reproduce that path shape directly so the regression
is caught on any platform."""
version = _asset_version_from_home(client)
static_dir = get_package_resource_path("static")
handler = VersionedStaticFiles(directory=static_dir, version=version)
scope = {"type": "http", "method": "GET", "headers": []}

# Backslash separator, as os.path.normpath would produce on Windows.
response = asyncio.run(handler.get_response(f"{version}\\main.js", scope))

assert response.status_code == 200
assert "immutable" in response.headers.get("cache-control", "")
Loading