refactor: apply custom theme without cache reload
This commit is contained in:
@@ -37,6 +37,46 @@ WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]:
|
||||
if hasattr(theme, "model_dump"):
|
||||
data = theme.model_dump(mode="json", exclude_none=True)
|
||||
elif isinstance(theme, dict):
|
||||
data = dict(theme)
|
||||
else:
|
||||
data = {}
|
||||
data.pop("assets_version", None)
|
||||
data.pop("default", None)
|
||||
return data
|
||||
|
||||
|
||||
def _bump_theme_asset_versions(
|
||||
config: WebappThemesConfig,
|
||||
previous: WebappThemesConfig,
|
||||
) -> WebappThemesConfig:
|
||||
previous_by_key = {theme.key: theme for theme in previous.themes}
|
||||
default_changed = config.default_theme != previous.default_theme
|
||||
data = config.model_dump(mode="json", exclude_none=True)
|
||||
for theme in data.get("themes", []):
|
||||
if not isinstance(theme, dict):
|
||||
continue
|
||||
if not str(theme.get("css_file") or "").strip():
|
||||
continue
|
||||
key = str(theme.get("key") or "")
|
||||
previous_theme = previous_by_key.get(key)
|
||||
previous_version = int(getattr(previous_theme, "assets_version", 0) or 0)
|
||||
current_version = int(theme.get("assets_version") or 1)
|
||||
theme_changed = (
|
||||
previous_theme is None
|
||||
or _theme_payload_for_version_compare(theme)
|
||||
!= _theme_payload_for_version_compare(previous_theme)
|
||||
)
|
||||
if theme_changed or (default_changed and key == config.default_theme):
|
||||
theme["assets_version"] = max(previous_version + 1, current_version, 1)
|
||||
elif previous_version > current_version:
|
||||
theme["assets_version"] = previous_version
|
||||
return WebappThemesConfig.model_validate(data)
|
||||
|
||||
|
||||
def _detect_logo_extension(
|
||||
body: bytes, content_type: str = "", filename: str = ""
|
||||
) -> Optional[str]:
|
||||
@@ -420,6 +460,11 @@ async def admin_themes_get_route(request: web.Request) -> web.Response:
|
||||
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
previous_config = resolved_webapp_themes_catalog(
|
||||
primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
|
||||
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
|
||||
theme_dir=settings.WEBAPP_THEMES_DIR,
|
||||
)
|
||||
payload = await _read_json(request)
|
||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||
if not isinstance(catalog, dict):
|
||||
@@ -431,6 +476,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||
|
||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||
config = _bump_theme_asset_versions(config, previous_config)
|
||||
|
||||
try:
|
||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
||||
|
||||
@@ -94,7 +94,10 @@ async def theme_css_asset_route(request: web.Request) -> web.Response:
|
||||
except ValueError:
|
||||
raise web.HTTPNotFound(text="theme_css_not_found") from None
|
||||
|
||||
cache_control = "no-cache"
|
||||
query = getattr(request, "query", {}) or {}
|
||||
cache_control = (
|
||||
"public, max-age=31536000, immutable" if query.get("v") else "no-cache"
|
||||
)
|
||||
try:
|
||||
stat = path.stat()
|
||||
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
|
||||
@@ -1498,7 +1501,14 @@ def _theme_css_href_for_html(theme: Any) -> str:
|
||||
return ""
|
||||
themed_path = "/".join([key, *parts])
|
||||
encoded = "/".join(quote(part, safe="") for part in themed_path.split("/"))
|
||||
return f"/webapp-theme-css/{encoded}" if encoded else ""
|
||||
href = f"/webapp-theme-css/{encoded}" if encoded else ""
|
||||
try:
|
||||
version = int(getattr(theme, "assets_version", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
version = 0
|
||||
if href and version > 0:
|
||||
href = f"{href}?v={quote(str(version), safe='')}"
|
||||
return href
|
||||
|
||||
|
||||
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
||||
|
||||
@@ -619,6 +619,7 @@ def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, o
|
||||
"enabled": bool(theme.enabled),
|
||||
"use_primary_accent": bool(theme.use_primary_accent),
|
||||
"use_in_admin": bool(theme.use_in_admin),
|
||||
"assets_version": int(theme.assets_version or 1),
|
||||
"tokens": tokens.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
if theme.css_file:
|
||||
|
||||
@@ -135,11 +135,20 @@ function encodeThemeCssPath(path) {
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function themeAssetsVersion(theme) {
|
||||
const version = Number(theme?.assets_version || 0);
|
||||
return Number.isFinite(version) && version > 0 ? String(Math.floor(version)) : "";
|
||||
}
|
||||
|
||||
export function themeCssHref(theme) {
|
||||
const cssFile = String(theme?.css_file || "").trim();
|
||||
if (!cssFile) return "";
|
||||
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return "";
|
||||
if (cssFile.startsWith("/")) return cssFile;
|
||||
const version = themeAssetsVersion(theme);
|
||||
if (cssFile.startsWith("/")) {
|
||||
if (!version) return cssFile;
|
||||
return `${cssFile}${cssFile.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
|
||||
}
|
||||
const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
|
||||
const key = String(theme?.key || "")
|
||||
.trim()
|
||||
@@ -150,7 +159,9 @@ export function themeCssHref(theme) {
|
||||
? `${key}/${normalizedCssFile}`
|
||||
: normalizedCssFile;
|
||||
const encoded = encodeThemeCssPath(themedPath);
|
||||
return encoded ? `/webapp-theme-css/${encoded}` : "";
|
||||
if (!encoded) return "";
|
||||
const href = `/webapp-theme-css/${encoded}`;
|
||||
return version ? `${href}?v=${encodeURIComponent(version)}` : href;
|
||||
}
|
||||
|
||||
export function localizedThemeName(theme, lang = "en") {
|
||||
|
||||
@@ -16,7 +16,7 @@ from bot.app.web import subscription_webapp
|
||||
from bot.app.web.admin_api_impl import themes as admin_themes
|
||||
from bot.app.web.webapp import assets as webapp_assets
|
||||
from config.settings import Settings
|
||||
from config.webapp_themes_config import builtin_webapp_themes_config
|
||||
from config.webapp_themes_config import WebappThemesConfig, builtin_webapp_themes_config
|
||||
|
||||
|
||||
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -281,11 +281,51 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456")
|
||||
|
||||
self.assertIn("/webapp-theme-css/light/style.css", markup)
|
||||
self.assertIn("/webapp-theme-css/light/style.css?v=", markup)
|
||||
self.assertIn('nonce="nonce-value"', markup)
|
||||
self.assertIn("--accent:#123456", markup)
|
||||
self.assertIn("color-scheme:light", markup)
|
||||
|
||||
def test_theme_asset_version_bumps_for_saved_default_css_theme(self):
|
||||
previous = WebappThemesConfig(
|
||||
default_theme="dark",
|
||||
themes=[
|
||||
{
|
||||
"key": "dark",
|
||||
"default": True,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
},
|
||||
{
|
||||
"key": "custom",
|
||||
"default": False,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
},
|
||||
],
|
||||
)
|
||||
updated = WebappThemesConfig(
|
||||
default_theme="custom",
|
||||
themes=[
|
||||
{
|
||||
"key": "dark",
|
||||
"default": False,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
},
|
||||
{
|
||||
"key": "custom",
|
||||
"default": True,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"tokens": {"color_scheme": "dark"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
bumped = admin_themes._bump_theme_asset_versions(updated, previous)
|
||||
|
||||
self.assertEqual(bumped.theme_by_key("custom").assets_version, 4)
|
||||
|
||||
def test_animated_emoji_asset_path_uses_same_origin_route(self):
|
||||
self.assertEqual(
|
||||
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
|
||||
@@ -588,6 +628,30 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("ETag", response.headers)
|
||||
self.assertIn("--bg: red", response.text)
|
||||
|
||||
async def test_theme_css_asset_route_uses_immutable_cache_when_versioned(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
themes_dir = Path(tmpdir)
|
||||
(themes_dir / "custom").mkdir()
|
||||
(themes_dir / "custom" / "theme.css").write_text(
|
||||
".theme-key-custom { --bg: red; }", encoding="utf-8"
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": SimpleNamespace(
|
||||
WEBAPP_ENABLED=True,
|
||||
WEBAPP_THEMES_DIR=str(themes_dir),
|
||||
)
|
||||
},
|
||||
match_info={"path": "custom/theme.css"},
|
||||
query={"v": "2"},
|
||||
)
|
||||
|
||||
response = await subscription_webapp.theme_css_asset_route(request)
|
||||
|
||||
self.assertEqual(
|
||||
response.headers["Cache-Control"], "public, max-age=31536000, immutable"
|
||||
)
|
||||
|
||||
async def test_theme_css_asset_route_returns_not_modified_for_matching_etag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
themes_dir = Path(tmpdir)
|
||||
|
||||
@@ -273,6 +273,7 @@ class WebappThemesConfigTests(unittest.TestCase):
|
||||
win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95")
|
||||
|
||||
self.assertEqual(light["css_file"], "style.css")
|
||||
self.assertEqual(light["assets_version"], cfg.theme_by_key("light").assets_version)
|
||||
self.assertEqual(light["tokens"]["accent"], "#abc123")
|
||||
self.assertEqual(dark["tokens"]["accent"], "#abc123")
|
||||
self.assertFalse(win95["use_primary_accent"])
|
||||
|
||||
Reference in New Issue
Block a user