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(
|
def _detect_logo_extension(
|
||||||
body: bytes, content_type: str = "", filename: str = ""
|
body: bytes, content_type: str = "", filename: str = ""
|
||||||
) -> Optional[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:
|
async def admin_themes_save_route(request: web.Request) -> web.Response:
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
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)
|
payload = await _read_json(request)
|
||||||
catalog = payload.get("catalog") if "catalog" in payload else payload
|
catalog = payload.get("catalog") if "catalog" in payload else payload
|
||||||
if not isinstance(catalog, dict):
|
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))
|
return _error(400, "invalid_webapp_themes_config", str(exc))
|
||||||
|
|
||||||
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
|
||||||
|
config = _bump_theme_asset_versions(config, previous_config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
|
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:
|
except ValueError:
|
||||||
raise web.HTTPNotFound(text="theme_css_not_found") from None
|
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:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
|
if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES:
|
||||||
@@ -1498,7 +1501,14 @@ def _theme_css_href_for_html(theme: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
themed_path = "/".join([key, *parts])
|
themed_path = "/".join([key, *parts])
|
||||||
encoded = "/".join(quote(part, safe="") for part in themed_path.split("/"))
|
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:
|
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),
|
"enabled": bool(theme.enabled),
|
||||||
"use_primary_accent": bool(theme.use_primary_accent),
|
"use_primary_accent": bool(theme.use_primary_accent),
|
||||||
"use_in_admin": bool(theme.use_in_admin),
|
"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),
|
"tokens": tokens.model_dump(mode="json", exclude_none=True),
|
||||||
}
|
}
|
||||||
if theme.css_file:
|
if theme.css_file:
|
||||||
|
|||||||
@@ -135,11 +135,20 @@ function encodeThemeCssPath(path) {
|
|||||||
.join("/");
|
.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) {
|
export function themeCssHref(theme) {
|
||||||
const cssFile = String(theme?.css_file || "").trim();
|
const cssFile = String(theme?.css_file || "").trim();
|
||||||
if (!cssFile) return "";
|
if (!cssFile) return "";
|
||||||
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) 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 normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
|
||||||
const key = String(theme?.key || "")
|
const key = String(theme?.key || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -150,7 +159,9 @@ export function themeCssHref(theme) {
|
|||||||
? `${key}/${normalizedCssFile}`
|
? `${key}/${normalizedCssFile}`
|
||||||
: normalizedCssFile;
|
: normalizedCssFile;
|
||||||
const encoded = encodeThemeCssPath(themedPath);
|
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") {
|
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.admin_api_impl import themes as admin_themes
|
||||||
from bot.app.web.webapp import assets as webapp_assets
|
from bot.app.web.webapp import assets as webapp_assets
|
||||||
from config.settings import Settings
|
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):
|
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||||
@@ -281,11 +281,51 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456")
|
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('nonce="nonce-value"', markup)
|
||||||
self.assertIn("--accent:#123456", markup)
|
self.assertIn("--accent:#123456", markup)
|
||||||
self.assertIn("color-scheme:light", 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):
|
def test_animated_emoji_asset_path_uses_same_origin_route(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
|
subscription_webapp._webapp_animated_emoji_asset_path("🤩"),
|
||||||
@@ -588,6 +628,30 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("ETag", response.headers)
|
self.assertIn("ETag", response.headers)
|
||||||
self.assertIn("--bg: red", response.text)
|
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):
|
async def test_theme_css_asset_route_returns_not_modified_for_matching_etag(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
themes_dir = Path(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")
|
win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95")
|
||||||
|
|
||||||
self.assertEqual(light["css_file"], "style.css")
|
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(light["tokens"]["accent"], "#abc123")
|
||||||
self.assertEqual(dark["tokens"]["accent"], "#abc123")
|
self.assertEqual(dark["tokens"]["accent"], "#abc123")
|
||||||
self.assertFalse(win95["use_primary_accent"])
|
self.assertFalse(win95["use_primary_accent"])
|
||||||
|
|||||||
Reference in New Issue
Block a user