diff --git a/.gitignore b/.gitignore index 8601830..81bef46 100644 --- a/.gitignore +++ b/.gitignore @@ -17,10 +17,34 @@ bot/app/web/templates/subscription_webapp.css bot/app/web/templates/subscription_webapp.js bot/app/web/templates/subscription_webapp.min.*.js bot/app/web/templates/subscription_webapp.*.css +bot/app/web/templates/subscription_webapp.min.*.js.br +bot/app/web/templates/subscription_webapp.min.*.js.gz +bot/app/web/templates/subscription_webapp.*.css.br +bot/app/web/templates/subscription_webapp.*.css.gz +bot/app/web/templates/subscription_webapp_admin.css +bot/app/web/templates/subscription_webapp_admin.js +bot/app/web/templates/subscription_webapp_admin.min.*.js +bot/app/web/templates/subscription_webapp_admin.*.css +bot/app/web/templates/subscription_webapp_admin.min.*.js.br +bot/app/web/templates/subscription_webapp_admin.min.*.js.gz +bot/app/web/templates/subscription_webapp_admin.*.css.br +bot/app/web/templates/subscription_webapp_admin.*.css.gz backend/bot/app/web/templates/subscription_webapp.css backend/bot/app/web/templates/subscription_webapp.js backend/bot/app/web/templates/subscription_webapp.min.*.js backend/bot/app/web/templates/subscription_webapp.*.css +backend/bot/app/web/templates/subscription_webapp.min.*.js.br +backend/bot/app/web/templates/subscription_webapp.min.*.js.gz +backend/bot/app/web/templates/subscription_webapp.*.css.br +backend/bot/app/web/templates/subscription_webapp.*.css.gz +backend/bot/app/web/templates/subscription_webapp_admin.css +backend/bot/app/web/templates/subscription_webapp_admin.js +backend/bot/app/web/templates/subscription_webapp_admin.min.*.js +backend/bot/app/web/templates/subscription_webapp_admin.*.css +backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br +backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz +backend/bot/app/web/templates/subscription_webapp_admin.*.css.br +backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz tmp .claude diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index be63e0e..bd467f7 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -1,5 +1,6 @@ # ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 +import gzip from config.webapp_themes_config import ( default_webapp_theme_asset_file, @@ -10,6 +11,8 @@ from config.webapp_themes_config import ( ) _TEXT_FILE_CACHE: Dict[tuple[str, bool], tuple[int, int, str]] = {} +_BINARY_FILE_CACHE: Dict[str, tuple[int, int, bytes]] = {} +_GZIP_BODY_CACHE: Dict[str, bytes] = {} _ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {} _I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {} _ASSET_NAME_CACHE_TTL_SECONDS = 30.0 @@ -20,9 +23,22 @@ async def health_route(request: web.Request) -> web.Response: async def css_asset_route(request: web.Request) -> web.Response: + return await _css_asset_route(request, base_name="subscription_webapp") + + +async def admin_css_asset_route(request: web.Request) -> web.Response: + return await _css_asset_route(request, base_name="subscription_webapp_admin") + + +async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Response: asset_hash = request.match_info.get("asset_hash") - filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.css" - response = await _serve_template_asset(request, filename, "text/css") + filename = f"{base_name}.{asset_hash}.css" if asset_hash else f"{base_name}.css" + response = await _serve_template_asset( + request, + filename, + "text/css", + allow_precompressed=bool(asset_hash), + ) response.headers["Cache-Control"] = ( "public, max-age=31536000, immutable" if asset_hash else "no-cache" ) @@ -78,19 +94,48 @@ 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" try: - if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES: + stat = path.stat() + if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES: raise web.HTTPNotFound(text="theme_css_too_large") + etag = _theme_asset_etag( + "theme-css", + rel_path, + stat_mtime_ns=stat.st_mtime_ns, + size=stat.st_size, + ) + if _request_etag_matches(request, etag): + return _not_modified_response( + cache_control=cache_control, + etag=etag, + vary="Accept-Encoding", + ) text = path.read_text(encoding="utf-8") except OSError: defaults = default_webapp_theme_css_files() text = defaults.get(rel_path.as_posix()) if text is None: raise web.HTTPNotFound(text="theme_css_not_found") from None + etag = _theme_asset_etag( + "theme-css", + rel_path, + body=text.encode("utf-8"), + ) + if _request_etag_matches(request, etag): + return _not_modified_response( + cache_control=cache_control, + etag=etag, + vary="Accept-Encoding", + ) - response = web.Response(text=text, content_type="text/css", charset="utf-8") - response.headers["Cache-Control"] = "no-cache" - return response + return _theme_text_response( + request, + text, + content_type="text/css", + cache_control=cache_control, + etag=etag, + ) async def theme_asset_route(request: web.Request) -> web.Response: @@ -115,9 +160,23 @@ async def theme_asset_route(request: web.Request) -> web.Response: if not content_type: raise web.HTTPNotFound(text="theme_asset_not_found") + query = getattr(request, "query", {}) + cache_control = ( + "public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600" + ) + try: - if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES: + stat = path.stat() + if stat.st_size > WEBAPP_THEME_ASSET_MAX_BYTES: raise web.HTTPNotFound(text="theme_asset_too_large") + etag = _theme_asset_etag( + "theme-asset", + rel_path, + stat_mtime_ns=stat.st_mtime_ns, + size=stat.st_size, + ) + if _request_etag_matches(request, etag): + return _not_modified_response(cache_control=cache_control, etag=etag) body = path.read_bytes() except OSError: fallback = default_webapp_theme_asset_file(rel_path) @@ -125,15 +184,16 @@ async def theme_asset_route(request: web.Request) -> web.Response: raise web.HTTPNotFound(text="theme_asset_not_found") from None body, fallback_suffix = fallback content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type) + etag = _theme_asset_etag("theme-asset", rel_path, body=body) + if _request_etag_matches(request, etag): + return _not_modified_response(cache_control=cache_control, etag=etag) if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES: raise web.HTTPNotFound(text="theme_asset_not_found") - query = getattr(request, "query", {}) response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = ( - "public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600" - ) + response.headers["Cache-Control"] = cache_control + response.headers["ETag"] = etag return response @@ -885,14 +945,21 @@ async def _enforce_webapp_rate_limit( async def js_asset_route(request: web.Request) -> web.Response: + return await _js_asset_route(request, base_name="subscription_webapp") + + +async def admin_js_asset_route(request: web.Request) -> web.Response: + return await _js_asset_route(request, base_name="subscription_webapp_admin") + + +async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Response: asset_hash = request.match_info.get("asset_hash") - filename = ( - f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js" - ) + filename = f"{base_name}.min.{asset_hash}.js" if asset_hash else f"{base_name}.js" response = await _serve_template_asset( request, filename, "application/javascript", + allow_precompressed=bool(asset_hash), strip_dev_mock=not asset_hash, ) response.headers["Cache-Control"] = ( @@ -986,6 +1053,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]: "faviconUrl": cached["favicon_url"], "faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM), "apiBase": "/api", + "adminJsAsset": f"/{_resolve_webapp_admin_js_asset_name()}", + "adminCssAsset": f"/{_resolve_webapp_admin_css_asset_name()}", "telegramLoginBotUsername": request.app.get("bot_username") or "", "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, @@ -1100,6 +1169,7 @@ async def _serve_template_asset( filename: str, content_type: str, *, + allow_precompressed: bool = False, strip_dev_mock: bool = False, ) -> web.Response: settings: Settings = request.app["settings"] @@ -1107,10 +1177,169 @@ async def _serve_template_asset( raise web.HTTPNotFound(text="webapp_disabled") path = ASSET_DIR / filename + if allow_precompressed: + compressed = _precompressed_template_asset_response(request, path, content_type) + if compressed is not None: + return compressed + text = _read_template_text_cached(path, strip_dev_mock=strip_dev_mock) return web.Response(text=text, content_type=content_type, charset="utf-8") +def _precompressed_template_asset_response( + request: web.Request, + path: Path, + content_type: str, +) -> Optional[web.Response]: + for encoding, suffix in (("br", ".br"), ("gzip", ".gz")): + if not _request_accepts_encoding(request, encoding): + continue + compressed_path = path.with_name(f"{path.name}{suffix}") + try: + body = _read_template_binary_cached(compressed_path) + except OSError: + continue + response = web.Response(body=body, content_type=content_type) + response.headers["Content-Encoding"] = encoding + response.headers["Vary"] = "Accept-Encoding" + return response + return None + + +def _request_accepts_encoding(request: web.Request, encoding: str) -> bool: + headers = getattr(request, "headers", {}) or {} + value = str(headers.get("Accept-Encoding", "")) + if not value: + return False + + expected = encoding.lower() + for part in value.split(","): + token, *params = part.strip().split(";") + token = token.strip().lower() + if token not in {expected, "*"}: + continue + for param in params: + param = param.strip().lower() + if not param.startswith("q="): + continue + try: + if float(param[2:].strip()) <= 0: + return False + except ValueError: + return False + return True + return False + + +def _request_etag_matches(request: web.Request, etag: str) -> bool: + headers = getattr(request, "headers", {}) or {} + value = str(headers.get("If-None-Match", "")) + if not value: + return False + if value.strip() == "*": + return True + + expected = _normalize_etag_for_compare(etag) + return any(_normalize_etag_for_compare(part.strip()) == expected for part in value.split(",")) + + +def _normalize_etag_for_compare(value: str) -> str: + text = str(value or "").strip() + if text.lower().startswith("w/"): + text = text[2:].strip() + return text + + +def _theme_asset_etag( + kind: str, + rel_path: Path, + *, + stat_mtime_ns: int = 0, + size: int = 0, + body: bytes = b"", +) -> str: + if body: + digest = hashlib.sha256( + b"\0".join( + [ + kind.encode("utf-8"), + rel_path.as_posix().encode("utf-8"), + body, + ] + ) + ).hexdigest()[:16] + else: + raw = f"{kind}:{rel_path.as_posix()}:{int(stat_mtime_ns)}:{int(size)}" + digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + return f'W/"{digest}"' + + +def _not_modified_response( + *, + cache_control: str, + etag: str, + vary: Optional[str] = None, +) -> web.Response: + response = web.Response(status=304) + response.headers["Cache-Control"] = cache_control + response.headers["ETag"] = etag + if vary: + response.headers["Vary"] = vary + return response + + +def _theme_text_response( + request: web.Request, + text: str, + *, + content_type: str, + cache_control: str, + etag: str, +) -> web.Response: + body = text.encode("utf-8") + if _request_accepts_encoding(request, "gzip"): + response = web.Response( + body=_gzip_body_cached(etag, body), + content_type=content_type, + charset="utf-8", + ) + response.headers["Content-Encoding"] = "gzip" + response.headers["Vary"] = "Accept-Encoding" + else: + response = web.Response(text=text, content_type=content_type, charset="utf-8") + response.headers["Cache-Control"] = cache_control + response.headers["ETag"] = etag + return response + + +def _gzip_body_cached(cache_key: str, body: bytes) -> bytes: + cached = _GZIP_BODY_CACHE.get(cache_key) + if cached is not None: + return cached + + compressed = gzip.compress(body, compresslevel=9, mtime=0) + _GZIP_BODY_CACHE[cache_key] = compressed + if len(_GZIP_BODY_CACHE) > 24: + _GZIP_BODY_CACHE.clear() + _GZIP_BODY_CACHE[cache_key] = compressed + return compressed + + +def _read_template_binary_cached(path: Path) -> bytes: + stat = path.stat() + key = str(path.resolve()) + cached = _BINARY_FILE_CACHE.get(key) + if cached and cached[0] == stat.st_mtime_ns and cached[1] == stat.st_size: + return cached[2] + + body = path.read_bytes() + _BINARY_FILE_CACHE[key] = (stat.st_mtime_ns, stat.st_size, body) + if len(_BINARY_FILE_CACHE) > 24: + _BINARY_FILE_CACHE.clear() + _BINARY_FILE_CACHE[key] = (stat.st_mtime_ns, stat.st_size, body) + return body + + def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> str: stat = path.stat() key = (str(path.resolve()), strip_dev_mock) @@ -1133,28 +1362,60 @@ def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> s def _resolve_webapp_js_asset_name() -> str: - cached = _get_cached_asset_name("js") + return _resolve_hashed_js_asset_name( + kind="js", + base_name="subscription_webapp", + ) + + +def _resolve_webapp_admin_js_asset_name() -> str: + return _resolve_hashed_js_asset_name( + kind="admin-js", + base_name="subscription_webapp_admin", + ) + + +def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str: + cached = _get_cached_asset_name(kind) if cached: return cached minified_assets = [] - for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): + pattern = re.compile(rf"{re.escape(base_name)}\.min\.[0-9a-f]{{8}}\.js") + for path in ASSET_DIR.glob(f"{base_name}.min.*.js"): + if not pattern.fullmatch(path.name): + continue try: minified_assets.append((path.stat().st_mtime, path.name)) except OSError: continue if minified_assets: minified_assets.sort(reverse=True) - return _set_cached_asset_name("js", minified_assets[0][1]) - return _set_cached_asset_name("js", "subscription_webapp.js") + return _set_cached_asset_name(kind, minified_assets[0][1]) + return _set_cached_asset_name(kind, f"{base_name}.js") def _resolve_webapp_css_asset_name() -> str: - cached = _get_cached_asset_name("css") + return _resolve_hashed_css_asset_name( + kind="css", + base_name="subscription_webapp", + ) + + +def _resolve_webapp_admin_css_asset_name() -> str: + return _resolve_hashed_css_asset_name( + kind="admin-css", + base_name="subscription_webapp_admin", + ) + + +def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str: + cached = _get_cached_asset_name(kind) if cached: return cached hashed_assets = [] - for path in ASSET_DIR.glob("subscription_webapp.*.css"): - if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name): + pattern = re.compile(rf"{re.escape(base_name)}\.[0-9a-f]{{8}}\.css") + for path in ASSET_DIR.glob(f"{base_name}.*.css"): + if not pattern.fullmatch(path.name): continue try: hashed_assets.append((path.stat().st_mtime, path.name)) @@ -1162,8 +1423,8 @@ def _resolve_webapp_css_asset_name() -> str: continue if hashed_assets: hashed_assets.sort(reverse=True) - return _set_cached_asset_name("css", hashed_assets[0][1]) - return _set_cached_asset_name("css", "subscription_webapp.css") + return _set_cached_asset_name(kind, hashed_assets[0][1]) + return _set_cached_asset_name(kind, f"{base_name}.css") def _get_cached_asset_name(kind: str) -> Optional[str]: diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 7fb0b94..42afb2e 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -39,10 +39,17 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: ) app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route) app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get( + "/subscription_webapp_admin.{asset_hash:[0-9a-f]{8}}.css", + admin_css_asset_route, + ) + app.router.add_get("/subscription_webapp_admin.css", admin_css_asset_route) app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route) app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route) app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) app.router.add_get("/subscription_webapp.js", js_asset_route) + app.router.add_get("/subscription_webapp_admin.min.{asset_hash}.js", admin_js_asset_route) + app.router.add_get("/subscription_webapp_admin.js", admin_js_asset_route) app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) app.router.add_post("/api/auth/token", auth_token_route) app.router.add_post("/api/auth/email/request", email_auth_request_route) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 0de24ea..b2868f4 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -106,20 +106,44 @@ COPY --from=version-builder /build-commit /build-commit COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css.gz /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.css /usr/share/nginx/html/subscription_webapp_admin.css +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz /usr/share/nginx/html/ COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js.gz /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.js /usr/share/nginx/html/subscription_webapp_admin.js +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js /usr/share/nginx/html/ +COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz /usr/share/nginx/html/ RUN set -eu; \ find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \ HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ JS_NAME="${HASHED:-subscription_webapp.js}"; \ CSS_NAME="subscription_webapp.css"; \ + ADMIN_HASHED=$(ls /usr/share/nginx/html/subscription_webapp_admin.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \ + ADMIN_CSS_NAME="subscription_webapp_admin.css"; \ for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \ name="$(basename "$candidate")"; \ case "$name" in \ subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \ esac; \ done; \ + for candidate in /usr/share/nginx/html/subscription_webapp_admin.*.css; do \ + name="$(basename "$candidate")"; \ + case "$name" in \ + subscription_webapp_admin.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) ADMIN_CSS_NAME="$name" ;; \ + esac; \ + done; \ + if [ -n "$ADMIN_HASHED" ]; then \ + cp "/usr/share/nginx/html/${ADMIN_HASHED}" /usr/share/nginx/html/subscription_webapp_admin.js; \ + if [ -f "/usr/share/nginx/html/${ADMIN_HASHED}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_HASHED}.gz" /usr/share/nginx/html/subscription_webapp_admin.js.gz; fi; \ + fi; \ + if [ "$ADMIN_CSS_NAME" != "subscription_webapp_admin.css" ]; then \ + cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}" /usr/share/nginx/html/subscription_webapp_admin.css; \ + if [ -f "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" /usr/share/nginx/html/subscription_webapp_admin.css.gz; fi; \ + fi; \ sed -i \ -e '/WEBAPP_I18N_SCRIPT/d' \ -e '/WEBAPP_CONFIG_SCRIPT/d' \ diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index bc6c684..d3704ac 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -5,6 +5,7 @@ server { index index.html; gzip on; + gzip_static on; gzip_comp_level 5; gzip_min_length 1024; gzip_vary on; @@ -58,13 +59,13 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~* ^/subscription_webapp\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ { + location ~* ^/subscription_webapp(_admin)?\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ { expires off; add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } - location ~* ^/subscription_webapp\.(css|js)$ { + location ~* ^/subscription_webapp(_admin)?\.(css|js)$ { expires off; add_header Cache-Control "no-cache"; try_files $uri =404; diff --git a/frontend/package.json b/frontend/package.json index 5c2eeff..0ef114a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,9 @@ { "private": true, "scripts": { - "build:webapp:svelte": "vite build --config ./vite.config.mjs", + "build:webapp:svelte:main": "vite build --config ./vite.config.mjs", + "build:webapp:svelte:admin": "vite build --config ./vite.config.mjs --mode admin", + "build:webapp:svelte": "npm run build:webapp:svelte:main && npm run build:webapp:svelte:admin", "build:webapp:css": "npm run build:webapp:svelte", "build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs", "build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js", diff --git a/frontend/scripts/build_subscription_webapp_js.mjs b/frontend/scripts/build_subscription_webapp_js.mjs index 451f2b8..70eee28 100644 --- a/frontend/scripts/build_subscription_webapp_js.mjs +++ b/frontend/scripts/build_subscription_webapp_js.mjs @@ -3,29 +3,36 @@ import { createHash } from "node:crypto"; import { readFile, readdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib"; import { transform } from "esbuild"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); -const sourcePath = path.join( - repoRoot, - "backend", - "bot", - "app", - "web", - "templates", - "subscription_webapp.js" -); -const sourceCssPath = path.join( - repoRoot, - "backend", - "bot", - "app", - "web", - "templates", - "subscription_webapp.css" -); +const JS_MINIFY_TARGET = "es2020"; +const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates"); +const JS_BUILDS = [ + { + sourcePath: path.join(templatesDir, "subscription_webapp.js"), + outputPrefix: "subscription_webapp.min", + stripDevMock: true, + stripFallbackI18nPayload: true, + }, + { + sourcePath: path.join(templatesDir, "subscription_webapp_admin.js"), + outputPrefix: "subscription_webapp_admin.min", + }, +]; +const CSS_BUILDS = [ + { + sourcePath: path.join(templatesDir, "subscription_webapp.css"), + outputPrefix: "subscription_webapp", + }, + { + sourcePath: path.join(templatesDir, "subscription_webapp_admin.css"), + outputPrefix: "subscription_webapp_admin", + }, +]; function normalizeLineEndings(value) { return value.replace(/\r\n/g, "\n"); @@ -59,60 +66,111 @@ function stripFallbackI18n(source) { ); } -async function removeOldHashedAssets(assetDir, pattern, keepName) { +async function removeOldHashedAssets(assetDir, pattern, keepNames) { + const keep = new Set(Array.isArray(keepNames) ? keepNames : [keepNames]); const entries = await readdir(assetDir, { withFileTypes: true }); await Promise.all( entries - .filter((entry) => entry.isFile() && pattern.test(entry.name) && entry.name !== keepName) + .filter((entry) => entry.isFile() && pattern.test(entry.name) && !keep.has(entry.name)) .map((entry) => unlink(path.join(assetDir, entry.name))) ); } -async function main() { +async function writePrecompressedAssets(outputPath, body) { + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(body, "utf8"); + const gzipBody = gzipSync(buffer, { level: 9 }); + const brotliBody = brotliCompressSync(buffer, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + + await Promise.all([ + writeFile(`${outputPath}.gz`, gzipBody), + writeFile(`${outputPath}.br`, brotliBody), + ]); + + return { + gzip: gzipBody.length, + brotli: brotliBody.length, + }; +} + +async function buildJsAsset({ + sourcePath, + outputPrefix, + stripDevMock = false, + stripFallbackI18nPayload = false, +}) { const rawSource = await readFile(sourcePath, "utf8"); - const withoutMocks = stripMarkedBlock( - normalizeLineEndings(rawSource), - "/* WEBAPP_DEV_MOCK_START */", - "/* WEBAPP_DEV_MOCK_END */" - ); - const strippedSource = stripFallbackI18n(withoutMocks); + let strippedSource = normalizeLineEndings(rawSource); + if (stripDevMock) { + strippedSource = stripMarkedBlock( + strippedSource, + "/* WEBAPP_DEV_MOCK_START */", + "/* WEBAPP_DEV_MOCK_END */" + ); + } + if (stripFallbackI18nPayload) { + strippedSource = stripFallbackI18n(strippedSource); + } const result = await transform(strippedSource, { charset: "utf8", legalComments: "none", loader: "js", minify: true, - target: "es2018", + target: JS_MINIFY_TARGET, }); const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`; const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8); - const outputPath = path.join(path.dirname(sourcePath), `subscription_webapp.min.${hash}.js`); + const outputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${hash}.js`); + const outputName = path.basename(outputPath); + const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); await removeOldHashedAssets( path.dirname(sourcePath), - /^subscription_webapp\.min\.[0-9a-f]{8}\.js$/, - path.basename(outputPath) + new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.js(?:\\.(?:br|gz))?$`), + [outputName, `${outputName}.br`, `${outputName}.gz`] ); await writeFile(outputPath, code, "utf8"); + const compressedJs = await writePrecompressedAssets(outputPath, code); console.log( - `Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)` - ); - - const css = await readFile(sourceCssPath, "utf8"); - const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8); - const cssOutputPath = path.join( - path.dirname(sourceCssPath), - `subscription_webapp.${cssHash}.css` - ); - await removeOldHashedAssets( - path.dirname(sourceCssPath), - /^subscription_webapp\.[0-9a-f]{8}\.css$/, - path.basename(cssOutputPath) - ); - await writeFile(cssOutputPath, css, "utf8"); - console.log( - `Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes)` + `Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes, gzip ${compressedJs.gzip}, br ${compressedJs.brotli})` ); } +async function buildCssAsset({ sourcePath, outputPrefix }) { + const rawCss = await readFile(sourcePath, "utf8"); + const cssResult = await transform(rawCss, { + legalComments: "none", + loader: "css", + minify: true, + }); + const css = `${cssResult.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`; + const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8); + const cssOutputPath = path.join(path.dirname(sourcePath), `${outputPrefix}.${cssHash}.css`); + const cssOutputName = path.basename(cssOutputPath); + const escapedPrefix = outputPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await removeOldHashedAssets( + path.dirname(sourcePath), + new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.css(?:\\.(?:br|gz))?$`), + [cssOutputName, `${cssOutputName}.br`, `${cssOutputName}.gz`] + ); + await writeFile(cssOutputPath, css, "utf8"); + const compressedCss = await writePrecompressedAssets(cssOutputPath, css); + console.log( + `Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes, gzip ${compressedCss.gzip}, br ${compressedCss.brotli})` + ); +} + +async function main() { + for (const build of JS_BUILDS) { + await buildJsAsset(build); + } + for (const build of CSS_BUILDS) { + await buildCssAsset(build); + } +} + await main(); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5ec7dd1..9e2bff3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,7 +9,6 @@ import BrandMark from "$lib/webapp/BrandMark.svelte"; import PreviewBoard from "./PreviewBoard.svelte"; - import AdminPanel from "./admin/AdminPanel.svelte"; import WebAppShell from "./webapp/WebAppShell.svelte"; import AuthScreen from "./webapp/auth/AuthScreen.svelte"; import PaymentDialogs from "./webapp/PaymentDialogs.svelte"; @@ -120,6 +119,9 @@ let scrollLockApplied = false; let adminI18nLoaded = false; let adminI18nPromise = null; + let AdminPanelComponent = null; + let adminBundlePromise = null; + let adminBundleError = ""; let tg = null; const telegramSdk = createTelegramSdk({ scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL, @@ -485,12 +487,16 @@ if (mode === "app") { if (section === "admin" && isAdmin) { const pathAtStart = window.location.pathname; - void ensureI18nScope("admin").finally(() => { - if (sectionFromPath(window.location.pathname) !== "admin") return; - if (window.location.pathname !== pathAtStart) return; - activeTab = "settings"; - screen = "admin"; - }); + void Promise.all([ensureI18nScope("admin"), ensureAdminBundle()]) + .then(() => { + if (sectionFromPath(window.location.pathname) !== "admin") return; + if (window.location.pathname !== pathAtStart) return; + activeTab = "settings"; + screen = "admin"; + }) + .catch(() => { + showToast(t("wa_unavailable")); + }); return; } const nextSection = @@ -608,6 +614,71 @@ return adminI18nPromise; } + function resolveWebappAssetPath(configValue, fallbackName) { + const raw = String(configValue || "").trim() || fallbackName; + if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith("data:")) return fallbackName; + if (window.location.protocol === "file:" && raw.startsWith("/")) return raw.slice(1); + return raw.startsWith("/") ? raw : `/${raw}`; + } + + function appendStylesheetOnce(id, href) { + if (!href || document.getElementById(id)) return Promise.resolve(); + return new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.href = href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error(`stylesheet_load_failed:${href}`)); + document.head.appendChild(link); + }); + } + + function appendScriptOnce(id, src) { + if (!src || document.getElementById(id)) return Promise.resolve(); + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.id = id; + script.src = src; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`script_load_failed:${src}`)); + document.head.appendChild(script); + }); + } + + async function ensureAdminBundle() { + if (AdminPanelComponent) return true; + if (adminBundlePromise) return adminBundlePromise; + + const existing = window.SubscriptionWebAppAdminPanel; + if (existing) { + AdminPanelComponent = existing.default || existing; + return true; + } + + adminBundleError = ""; + adminBundlePromise = (async () => { + const cssHref = resolveWebappAssetPath(CFG.adminCssAsset, "subscription_webapp_admin.css"); + const jsSrc = resolveWebappAssetPath(CFG.adminJsAsset, "subscription_webapp_admin.js"); + await appendStylesheetOnce("subscription-webapp-admin-css", cssHref); + await appendScriptOnce("subscription-webapp-admin-js", jsSrc); + const loaded = window.SubscriptionWebAppAdminPanel; + if (!loaded) throw new Error("admin_bundle_missing_component"); + AdminPanelComponent = loaded.default || loaded; + return true; + })() + .catch((error) => { + adminBundleError = error?.message || "admin_bundle_load_failed"; + throw error; + }) + .finally(() => { + adminBundlePromise = null; + }); + + return adminBundlePromise; + } + async function boot() { await runWebappBoot({ MOCK, @@ -702,7 +773,15 @@ const initialAdminSection = section === "admin" ? adminSectionFromPath(window.location.pathname) : null; if (section === "admin" && payload.user?.is_admin) { - await ensureI18nScope("admin"); + try { + await ensureI18nScope("admin"); + await ensureAdminBundle(); + } catch (_error) { + void _error; + section = "settings"; + activeTab = "settings"; + showToast(t("wa_unavailable")); + } } const initialSupportTicketId = section === "support" ? supportTicketIdFromPath(window.location.pathname) : null; @@ -1000,7 +1079,14 @@ if (!isAdmin) return; clearLanguageClickGuard(); billingStore.closePaymentModal(); - await ensureI18nScope("admin"); + try { + await ensureI18nScope("admin"); + await ensureAdminBundle(); + } catch (_error) { + void _error; + showToast(t("wa_unavailable")); + return; + } activeTab = "settings"; screen = "admin"; syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname)); @@ -1135,28 +1221,36 @@ setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)} /> {:else if screen === "admin" && isAdmin} - showToast(text)} - initialSection={adminSectionFromPath(window.location.pathname)} - initialUserId={adminUserIdFromPath(window.location.pathname)} - onSectionChange={handleAdminSectionChange} - onSettingsSaved={handleAdminPersistedSaved} - onTariffsSaved={handleAdminPersistedSaved} - onThemesSaved={handleAdminPersistedSaved} - {brandTitle} - {brand} - appFaviconUrl={CFG.faviconUrl} - appFaviconUseCustom={CFG.faviconUseCustom} - appVersion={CFG.appVersion} - appRepositoryUrl={CFG.appRepositoryUrl} - {currentLang} - {languageOptions} - {languageBusy} - onLanguageChange={accountStore.updateAccountLanguage} - {t} - /> + {#if AdminPanelComponent} + showToast(text)} + initialSection={adminSectionFromPath(window.location.pathname)} + initialUserId={adminUserIdFromPath(window.location.pathname)} + onSectionChange={handleAdminSectionChange} + onSettingsSaved={handleAdminPersistedSaved} + onTariffsSaved={handleAdminPersistedSaved} + onThemesSaved={handleAdminPersistedSaved} + {brandTitle} + {brand} + appFaviconUrl={CFG.faviconUrl} + appFaviconUseCustom={CFG.faviconUseCustom} + appVersion={CFG.appVersion} + appRepositoryUrl={CFG.appRepositoryUrl} + {currentLang} + {languageOptions} + {languageBusy} + onLanguageChange={accountStore.updateAccountLanguage} + {t} + /> + {:else} +
+ +
{adminBundleError ? t("wa_unavailable") : t("wa_loading")}
+
+ {/if} {:else} "subscription_webapp.js", - cssFileName: "subscription_webapp", - }, - rolldownOptions: { - checks: { - pluginTimings: false, +export default defineConfig(({ mode }) => { + const isAdminBuild = mode === "admin"; + const outputBase = isAdminBuild ? "subscription_webapp_admin" : "subscription_webapp"; + + return { + resolve: { + alias: { + $lib: path.resolve(__dirname, "src/lib"), + $components: path.resolve(__dirname, "src/lib/components"), }, - output: { - assetFileNames: (assetInfo) => { - if (assetInfo.name && assetInfo.name.endsWith(".css")) { - return "subscription_webapp.css"; - } - return "subscription_webapp.[name][extname]"; + }, + plugins: [tailwindcss(), svelte()], + build: { + outDir: templateDir, + emptyOutDir: false, + minify: false, + sourcemap: false, + cssCodeSplit: false, + lib: { + entry: path.resolve(__dirname, isAdminBuild ? "src/adminEntry.js" : "src/main.js"), + name: isAdminBuild ? "SubscriptionWebAppAdmin" : "SubscriptionWebApp", + formats: ["iife"], + fileName: () => `${outputBase}.js`, + cssFileName: outputBase, + }, + rolldownOptions: { + checks: { + pluginTimings: false, + }, + output: { + assetFileNames: (assetInfo) => { + if (assetInfo.name && assetInfo.name.endsWith(".css")) { + return `${outputBase}.css`; + } + return `${outputBase}.[name][extname]`; + }, }, }, }, - }, + }; }); diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 48302de..f3cc38c 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -1,4 +1,5 @@ import asyncio +import gzip import io import json import os @@ -447,6 +448,38 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): "subscription_webapp.min.22222222.js", ) + def test_resolve_webapp_admin_asset_names_prefer_latest_minified_builds(self): + with tempfile.TemporaryDirectory() as tmpdir: + asset_dir = Path(tmpdir) + (asset_dir / "subscription_webapp_admin.js").write_text( + "console.log('admin fallback');", encoding="utf-8" + ) + (asset_dir / "subscription_webapp_admin.css").write_text( + ".admin{color:red}", encoding="utf-8" + ) + old_js = asset_dir / "subscription_webapp_admin.min.11111111.js" + new_js = asset_dir / "subscription_webapp_admin.min.22222222.js" + old_css = asset_dir / "subscription_webapp_admin.11111111.css" + new_css = asset_dir / "subscription_webapp_admin.22222222.css" + old_js.write_text("console.log('old');", encoding="utf-8") + new_js.write_text("console.log('new');", encoding="utf-8") + old_css.write_text(".old{}", encoding="utf-8") + new_css.write_text(".new{}", encoding="utf-8") + os.utime(old_js, (1, 1)) + os.utime(old_css, (1, 1)) + os.utime(new_js, (2, 2)) + os.utime(new_css, (2, 2)) + + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): + self.assertEqual( + subscription_webapp._resolve_webapp_admin_js_asset_name(), + "subscription_webapp_admin.min.22222222.js", + ) + self.assertEqual( + subscription_webapp._resolve_webapp_admin_css_asset_name(), + "subscription_webapp_admin.22222222.css", + ) + async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self): with tempfile.TemporaryDirectory() as tmpdir: asset_dir = Path(tmpdir) @@ -466,6 +499,71 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(response.text, "console.log('minified');") + async def test_admin_js_asset_route_serves_admin_bundle(self): + with tempfile.TemporaryDirectory() as tmpdir: + asset_dir = Path(tmpdir) + minified_asset = asset_dir / "subscription_webapp_admin.min.abcdef12.js" + minified_asset.write_text("console.log('admin');", encoding="utf-8") + + request = SimpleNamespace( + app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)}, + match_info={"asset_hash": "abcdef12"}, + ) + + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): + response = await subscription_webapp.admin_js_asset_route(request) + + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + self.assertEqual(response.text, "console.log('admin');") + + async def test_js_asset_route_prefers_precompressed_brotli_asset(self): + with tempfile.TemporaryDirectory() as tmpdir: + asset_dir = Path(tmpdir) + minified_asset = asset_dir / "subscription_webapp.min.abcdef12.js" + minified_asset.write_text("console.log('minified');", encoding="utf-8") + (asset_dir / "subscription_webapp.min.abcdef12.js.br").write_bytes(b"br-body") + + request = SimpleNamespace( + app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)}, + match_info={"asset_hash": "abcdef12"}, + headers={"Accept-Encoding": "gzip, br"}, + ) + + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): + response = await subscription_webapp.js_asset_route(request) + + self.assertEqual(response.body, b"br-body") + self.assertEqual(response.headers["Content-Encoding"], "br") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + + async def test_css_asset_route_falls_back_to_precompressed_gzip_asset(self): + with tempfile.TemporaryDirectory() as tmpdir: + asset_dir = Path(tmpdir) + css_asset = asset_dir / "subscription_webapp.abcdef12.css" + css_asset.write_text(".app{color:red}", encoding="utf-8") + (asset_dir / "subscription_webapp.abcdef12.css.gz").write_bytes(b"gz-body") + + request = SimpleNamespace( + app={"settings": SimpleNamespace(WEBAPP_ENABLED=True)}, + match_info={"asset_hash": "abcdef12"}, + headers={"Accept-Encoding": "gzip"}, + ) + + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): + response = await subscription_webapp.css_asset_route(request) + + self.assertEqual(response.body, b"gz-body") + self.assertEqual(response.headers["Content-Encoding"], "gzip") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + async def test_theme_css_asset_route_serves_file_from_configured_directory(self): with tempfile.TemporaryDirectory() as tmpdir: themes_dir = Path(tmpdir) @@ -487,8 +585,68 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.content_type, "text/css") self.assertEqual(response.headers["Cache-Control"], "no-cache") + self.assertIn("ETag", response.headers) self.assertIn("--bg: red", response.text) + async def test_theme_css_asset_route_returns_not_modified_for_matching_etag(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"}, + headers={}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + etag = response.headers["ETag"] + cached_request = SimpleNamespace( + app=request.app, + match_info=request.match_info, + headers={"If-None-Match": etag}, + ) + + cached_response = await subscription_webapp.theme_css_asset_route(cached_request) + + self.assertEqual(cached_response.status, 304) + self.assertEqual(cached_response.headers["ETag"], etag) + self.assertEqual(cached_response.headers["Cache-Control"], "no-cache") + + async def test_theme_css_asset_route_serves_cached_gzip_when_accepted(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; }\n", encoding="utf-8" + ) + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/theme.css"}, + headers={"Accept-Encoding": "gzip"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual(response.headers["Content-Encoding"], "gzip") + self.assertEqual(response.headers["Vary"], "Accept-Encoding") + self.assertEqual( + gzip.decompress(response.body).decode("utf-8"), + ".theme-key-custom { --bg: red; }\n", + ) + async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self): with tempfile.TemporaryDirectory() as tmpdir: request = SimpleNamespace( @@ -542,8 +700,41 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.content_type, "image/png") self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600") + self.assertIn("ETag", response.headers) self.assertEqual(response.body, b"png-bytes") + async def test_theme_asset_route_returns_not_modified_for_matching_etag(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom" / "icons").mkdir(parents=True) + (themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes") + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/icons/save.png"}, + query={}, + headers={}, + ) + + response = await subscription_webapp.theme_asset_route(request) + etag = response.headers["ETag"] + cached_request = SimpleNamespace( + app=request.app, + match_info=request.match_info, + query={}, + headers={"If-None-Match": etag}, + ) + + cached_response = await subscription_webapp.theme_asset_route(cached_request) + + self.assertEqual(cached_response.status, 304) + self.assertEqual(cached_response.headers["ETag"], etag) + self.assertEqual(cached_response.headers["Cache-Control"], "public, max-age=3600") + async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self): with tempfile.TemporaryDirectory() as tmpdir: themes_dir = Path(tmpdir) diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index a7e3847..6d98884 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -63,9 +63,12 @@ class WebAppRouteContractTests(unittest.TestCase): ("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route", ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", ("GET", "/subscription_webapp.css"): "css_asset_route", + ("GET", "/subscription_webapp_admin.css"): "admin_css_asset_route", ("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route", ("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route", ("GET", "/subscription_webapp.js"): "js_asset_route", + ("GET", "/subscription_webapp_admin.min.{asset_hash}.js"): "admin_js_asset_route", + ("GET", "/subscription_webapp_admin.js"): "admin_js_asset_route", ("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route", ("POST", "/api/auth/token"): "auth_token_route", ("POST", "/api/auth/email/request"): "email_auth_request_route",