perf: optimize webapp asset delivery, split admin webapp bundle
This commit is contained in:
+24
@@ -17,10 +17,34 @@ bot/app/web/templates/subscription_webapp.css
|
|||||||
bot/app/web/templates/subscription_webapp.js
|
bot/app/web/templates/subscription_webapp.js
|
||||||
bot/app/web/templates/subscription_webapp.min.*.js
|
bot/app/web/templates/subscription_webapp.min.*.js
|
||||||
bot/app/web/templates/subscription_webapp.*.css
|
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.css
|
||||||
backend/bot/app/web/templates/subscription_webapp.js
|
backend/bot/app/web/templates/subscription_webapp.js
|
||||||
backend/bot/app/web/templates/subscription_webapp.min.*.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.*.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
|
tmp
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# ruff: noqa: F401,F403,F405,I001
|
# ruff: noqa: F401,F403,F405,I001
|
||||||
from ._runtime import * # noqa: F403,F405
|
from ._runtime import * # noqa: F403,F405
|
||||||
|
import gzip
|
||||||
|
|
||||||
from config.webapp_themes_config import (
|
from config.webapp_themes_config import (
|
||||||
default_webapp_theme_asset_file,
|
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]] = {}
|
_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]] = {}
|
_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]] = {}
|
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
|
||||||
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
_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:
|
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")
|
asset_hash = request.match_info.get("asset_hash")
|
||||||
filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.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")
|
response = await _serve_template_asset(
|
||||||
|
request,
|
||||||
|
filename,
|
||||||
|
"text/css",
|
||||||
|
allow_precompressed=bool(asset_hash),
|
||||||
|
)
|
||||||
response.headers["Cache-Control"] = (
|
response.headers["Cache-Control"] = (
|
||||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
"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:
|
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"
|
||||||
try:
|
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")
|
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")
|
text = path.read_text(encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
defaults = default_webapp_theme_css_files()
|
defaults = default_webapp_theme_css_files()
|
||||||
text = defaults.get(rel_path.as_posix())
|
text = defaults.get(rel_path.as_posix())
|
||||||
if text is None:
|
if text is None:
|
||||||
raise web.HTTPNotFound(text="theme_css_not_found") from 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")
|
return _theme_text_response(
|
||||||
response.headers["Cache-Control"] = "no-cache"
|
request,
|
||||||
return response
|
text,
|
||||||
|
content_type="text/css",
|
||||||
|
cache_control=cache_control,
|
||||||
|
etag=etag,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def theme_asset_route(request: web.Request) -> web.Response:
|
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:
|
if not content_type:
|
||||||
raise web.HTTPNotFound(text="theme_asset_not_found")
|
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:
|
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")
|
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()
|
body = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
fallback = default_webapp_theme_asset_file(rel_path)
|
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
|
raise web.HTTPNotFound(text="theme_asset_not_found") from None
|
||||||
body, fallback_suffix = fallback
|
body, fallback_suffix = fallback
|
||||||
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type)
|
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:
|
if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES:
|
||||||
raise web.HTTPNotFound(text="theme_asset_not_found")
|
raise web.HTTPNotFound(text="theme_asset_not_found")
|
||||||
|
|
||||||
query = getattr(request, "query", {})
|
|
||||||
response = web.Response(body=body, content_type=content_type)
|
response = web.Response(body=body, content_type=content_type)
|
||||||
response.headers["Cache-Control"] = (
|
response.headers["Cache-Control"] = cache_control
|
||||||
"public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600"
|
response.headers["ETag"] = etag
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -885,14 +945,21 @@ async def _enforce_webapp_rate_limit(
|
|||||||
|
|
||||||
|
|
||||||
async def js_asset_route(request: web.Request) -> web.Response:
|
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")
|
asset_hash = request.match_info.get("asset_hash")
|
||||||
filename = (
|
filename = f"{base_name}.min.{asset_hash}.js" if asset_hash else f"{base_name}.js"
|
||||||
f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js"
|
|
||||||
)
|
|
||||||
response = await _serve_template_asset(
|
response = await _serve_template_asset(
|
||||||
request,
|
request,
|
||||||
filename,
|
filename,
|
||||||
"application/javascript",
|
"application/javascript",
|
||||||
|
allow_precompressed=bool(asset_hash),
|
||||||
strip_dev_mock=not asset_hash,
|
strip_dev_mock=not asset_hash,
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = (
|
response.headers["Cache-Control"] = (
|
||||||
@@ -986,6 +1053,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
|||||||
"faviconUrl": cached["favicon_url"],
|
"faviconUrl": cached["favicon_url"],
|
||||||
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
||||||
"apiBase": "/api",
|
"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 "",
|
"telegramLoginBotUsername": request.app.get("bot_username") or "",
|
||||||
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
|
"telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0,
|
||||||
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
||||||
@@ -1100,6 +1169,7 @@ async def _serve_template_asset(
|
|||||||
filename: str,
|
filename: str,
|
||||||
content_type: str,
|
content_type: str,
|
||||||
*,
|
*,
|
||||||
|
allow_precompressed: bool = False,
|
||||||
strip_dev_mock: bool = False,
|
strip_dev_mock: bool = False,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
@@ -1107,10 +1177,169 @@ async def _serve_template_asset(
|
|||||||
raise web.HTTPNotFound(text="webapp_disabled")
|
raise web.HTTPNotFound(text="webapp_disabled")
|
||||||
|
|
||||||
path = ASSET_DIR / filename
|
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)
|
text = _read_template_text_cached(path, strip_dev_mock=strip_dev_mock)
|
||||||
return web.Response(text=text, content_type=content_type, charset="utf-8")
|
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:
|
def _read_template_text_cached(path: Path, *, strip_dev_mock: bool = False) -> str:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
key = (str(path.resolve()), strip_dev_mock)
|
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:
|
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:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
minified_assets = []
|
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:
|
try:
|
||||||
minified_assets.append((path.stat().st_mtime, path.name))
|
minified_assets.append((path.stat().st_mtime, path.name))
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if minified_assets:
|
if minified_assets:
|
||||||
minified_assets.sort(reverse=True)
|
minified_assets.sort(reverse=True)
|
||||||
return _set_cached_asset_name("js", minified_assets[0][1])
|
return _set_cached_asset_name(kind, minified_assets[0][1])
|
||||||
return _set_cached_asset_name("js", "subscription_webapp.js")
|
return _set_cached_asset_name(kind, f"{base_name}.js")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_webapp_css_asset_name() -> str:
|
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:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
hashed_assets = []
|
hashed_assets = []
|
||||||
for path in ASSET_DIR.glob("subscription_webapp.*.css"):
|
pattern = re.compile(rf"{re.escape(base_name)}\.[0-9a-f]{{8}}\.css")
|
||||||
if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name):
|
for path in ASSET_DIR.glob(f"{base_name}.*.css"):
|
||||||
|
if not pattern.fullmatch(path.name):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
hashed_assets.append((path.stat().st_mtime, path.name))
|
hashed_assets.append((path.stat().st_mtime, path.name))
|
||||||
@@ -1162,8 +1423,8 @@ def _resolve_webapp_css_asset_name() -> str:
|
|||||||
continue
|
continue
|
||||||
if hashed_assets:
|
if hashed_assets:
|
||||||
hashed_assets.sort(reverse=True)
|
hashed_assets.sort(reverse=True)
|
||||||
return _set_cached_asset_name("css", hashed_assets[0][1])
|
return _set_cached_asset_name(kind, hashed_assets[0][1])
|
||||||
return _set_cached_asset_name("css", "subscription_webapp.css")
|
return _set_cached_asset_name(kind, f"{base_name}.css")
|
||||||
|
|
||||||
|
|
||||||
def _get_cached_asset_name(kind: str) -> Optional[str]:
|
def _get_cached_asset_name(kind: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -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.{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.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-css/{path:.+}", theme_css_asset_route)
|
||||||
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_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.min.{asset_hash}.js", js_asset_route)
|
||||||
app.router.add_get("/subscription_webapp.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/telegram/nonce", telegram_oauth_nonce_route)
|
||||||
app.router.add_post("/api/auth/token", auth_token_route)
|
app.router.add_post("/api/auth/token", auth_token_route)
|
||||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||||
|
|||||||
@@ -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 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/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 /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.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 /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; \
|
RUN set -eu; \
|
||||||
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
|
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); \
|
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}"; \
|
JS_NAME="${HASHED:-subscription_webapp.js}"; \
|
||||||
CSS_NAME="subscription_webapp.css"; \
|
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 \
|
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
|
||||||
name="$(basename "$candidate")"; \
|
name="$(basename "$candidate")"; \
|
||||||
case "$name" in \
|
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" ;; \
|
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; \
|
esac; \
|
||||||
done; \
|
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 \
|
sed -i \
|
||||||
-e '/WEBAPP_I18N_SCRIPT/d' \
|
-e '/WEBAPP_I18N_SCRIPT/d' \
|
||||||
-e '/WEBAPP_CONFIG_SCRIPT/d' \
|
-e '/WEBAPP_CONFIG_SCRIPT/d' \
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
|
gzip_static on;
|
||||||
gzip_comp_level 5;
|
gzip_comp_level 5;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -58,13 +59,13 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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;
|
expires off;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/subscription_webapp\.(css|js)$ {
|
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||||
expires off;
|
expires off;
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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:css": "npm run build:webapp:svelte",
|
||||||
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
|
"build:webapp:js": "node ./scripts/build_subscription_webapp_js.mjs",
|
||||||
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js",
|
"build:webapp": "npm run build:webapp:svelte && npm run build:webapp:js",
|
||||||
|
|||||||
@@ -3,29 +3,36 @@ import { createHash } from "node:crypto";
|
|||||||
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib";
|
||||||
|
|
||||||
import { transform } from "esbuild";
|
import { transform } from "esbuild";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||||
const sourcePath = path.join(
|
const JS_MINIFY_TARGET = "es2020";
|
||||||
repoRoot,
|
const templatesDir = path.join(repoRoot, "backend", "bot", "app", "web", "templates");
|
||||||
"backend",
|
const JS_BUILDS = [
|
||||||
"bot",
|
{
|
||||||
"app",
|
sourcePath: path.join(templatesDir, "subscription_webapp.js"),
|
||||||
"web",
|
outputPrefix: "subscription_webapp.min",
|
||||||
"templates",
|
stripDevMock: true,
|
||||||
"subscription_webapp.js"
|
stripFallbackI18nPayload: true,
|
||||||
);
|
},
|
||||||
const sourceCssPath = path.join(
|
{
|
||||||
repoRoot,
|
sourcePath: path.join(templatesDir, "subscription_webapp_admin.js"),
|
||||||
"backend",
|
outputPrefix: "subscription_webapp_admin.min",
|
||||||
"bot",
|
},
|
||||||
"app",
|
];
|
||||||
"web",
|
const CSS_BUILDS = [
|
||||||
"templates",
|
{
|
||||||
"subscription_webapp.css"
|
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) {
|
function normalizeLineEndings(value) {
|
||||||
return value.replace(/\r\n/g, "\n");
|
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 });
|
const entries = await readdir(assetDir, { withFileTypes: true });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries
|
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)))
|
.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 rawSource = await readFile(sourcePath, "utf8");
|
||||||
const withoutMocks = stripMarkedBlock(
|
let strippedSource = normalizeLineEndings(rawSource);
|
||||||
normalizeLineEndings(rawSource),
|
if (stripDevMock) {
|
||||||
"/* WEBAPP_DEV_MOCK_START */",
|
strippedSource = stripMarkedBlock(
|
||||||
"/* WEBAPP_DEV_MOCK_END */"
|
strippedSource,
|
||||||
);
|
"/* WEBAPP_DEV_MOCK_START */",
|
||||||
const strippedSource = stripFallbackI18n(withoutMocks);
|
"/* WEBAPP_DEV_MOCK_END */"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stripFallbackI18nPayload) {
|
||||||
|
strippedSource = stripFallbackI18n(strippedSource);
|
||||||
|
}
|
||||||
const result = await transform(strippedSource, {
|
const result = await transform(strippedSource, {
|
||||||
charset: "utf8",
|
charset: "utf8",
|
||||||
legalComments: "none",
|
legalComments: "none",
|
||||||
loader: "js",
|
loader: "js",
|
||||||
minify: true,
|
minify: true,
|
||||||
target: "es2018",
|
target: JS_MINIFY_TARGET,
|
||||||
});
|
});
|
||||||
|
|
||||||
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
|
const code = `${result.code.replace(/[ \t]+$/gm, "").trimEnd()}\n`;
|
||||||
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
|
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(
|
await removeOldHashedAssets(
|
||||||
path.dirname(sourcePath),
|
path.dirname(sourcePath),
|
||||||
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
|
new RegExp(`^${escapedPrefix}\\.[0-9a-f]{8}\\.js(?:\\.(?:br|gz))?$`),
|
||||||
path.basename(outputPath)
|
[outputName, `${outputName}.br`, `${outputName}.gz`]
|
||||||
);
|
);
|
||||||
await writeFile(outputPath, code, "utf8");
|
await writeFile(outputPath, code, "utf8");
|
||||||
|
const compressedJs = await writePrecompressedAssets(outputPath, code);
|
||||||
console.log(
|
console.log(
|
||||||
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`
|
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes, gzip ${compressedJs.gzip}, br ${compressedJs.brotli})`
|
||||||
);
|
|
||||||
|
|
||||||
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)`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
await main();
|
||||||
|
|||||||
+125
-31
@@ -9,7 +9,6 @@
|
|||||||
|
|
||||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||||
import PreviewBoard from "./PreviewBoard.svelte";
|
import PreviewBoard from "./PreviewBoard.svelte";
|
||||||
import AdminPanel from "./admin/AdminPanel.svelte";
|
|
||||||
import WebAppShell from "./webapp/WebAppShell.svelte";
|
import WebAppShell from "./webapp/WebAppShell.svelte";
|
||||||
import AuthScreen from "./webapp/auth/AuthScreen.svelte";
|
import AuthScreen from "./webapp/auth/AuthScreen.svelte";
|
||||||
import PaymentDialogs from "./webapp/PaymentDialogs.svelte";
|
import PaymentDialogs from "./webapp/PaymentDialogs.svelte";
|
||||||
@@ -120,6 +119,9 @@
|
|||||||
let scrollLockApplied = false;
|
let scrollLockApplied = false;
|
||||||
let adminI18nLoaded = false;
|
let adminI18nLoaded = false;
|
||||||
let adminI18nPromise = null;
|
let adminI18nPromise = null;
|
||||||
|
let AdminPanelComponent = null;
|
||||||
|
let adminBundlePromise = null;
|
||||||
|
let adminBundleError = "";
|
||||||
let tg = null;
|
let tg = null;
|
||||||
const telegramSdk = createTelegramSdk({
|
const telegramSdk = createTelegramSdk({
|
||||||
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
|
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
|
||||||
@@ -485,12 +487,16 @@
|
|||||||
if (mode === "app") {
|
if (mode === "app") {
|
||||||
if (section === "admin" && isAdmin) {
|
if (section === "admin" && isAdmin) {
|
||||||
const pathAtStart = window.location.pathname;
|
const pathAtStart = window.location.pathname;
|
||||||
void ensureI18nScope("admin").finally(() => {
|
void Promise.all([ensureI18nScope("admin"), ensureAdminBundle()])
|
||||||
if (sectionFromPath(window.location.pathname) !== "admin") return;
|
.then(() => {
|
||||||
if (window.location.pathname !== pathAtStart) return;
|
if (sectionFromPath(window.location.pathname) !== "admin") return;
|
||||||
activeTab = "settings";
|
if (window.location.pathname !== pathAtStart) return;
|
||||||
screen = "admin";
|
activeTab = "settings";
|
||||||
});
|
screen = "admin";
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showToast(t("wa_unavailable"));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextSection =
|
const nextSection =
|
||||||
@@ -608,6 +614,71 @@
|
|||||||
return adminI18nPromise;
|
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() {
|
async function boot() {
|
||||||
await runWebappBoot({
|
await runWebappBoot({
|
||||||
MOCK,
|
MOCK,
|
||||||
@@ -702,7 +773,15 @@
|
|||||||
const initialAdminSection =
|
const initialAdminSection =
|
||||||
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
|
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
|
||||||
if (section === "admin" && payload.user?.is_admin) {
|
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 =
|
const initialSupportTicketId =
|
||||||
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
|
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
|
||||||
@@ -1000,7 +1079,14 @@
|
|||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
clearLanguageClickGuard();
|
clearLanguageClickGuard();
|
||||||
billingStore.closePaymentModal();
|
billingStore.closePaymentModal();
|
||||||
await ensureI18nScope("admin");
|
try {
|
||||||
|
await ensureI18nScope("admin");
|
||||||
|
await ensureAdminBundle();
|
||||||
|
} catch (_error) {
|
||||||
|
void _error;
|
||||||
|
showToast(t("wa_unavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
activeTab = "settings";
|
activeTab = "settings";
|
||||||
screen = "admin";
|
screen = "admin";
|
||||||
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
|
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
|
||||||
@@ -1135,28 +1221,36 @@
|
|||||||
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
|
setPasswordLoginMode={(enabled) => setPasswordLoginMode(enabled)}
|
||||||
/>
|
/>
|
||||||
{:else if screen === "admin" && isAdmin}
|
{:else if screen === "admin" && isAdmin}
|
||||||
<AdminPanel
|
{#if AdminPanelComponent}
|
||||||
{api}
|
<svelte:component
|
||||||
onClose={closeAdminPanel}
|
this={AdminPanelComponent}
|
||||||
onToast={(text) => showToast(text)}
|
{api}
|
||||||
initialSection={adminSectionFromPath(window.location.pathname)}
|
onClose={closeAdminPanel}
|
||||||
initialUserId={adminUserIdFromPath(window.location.pathname)}
|
onToast={(text) => showToast(text)}
|
||||||
onSectionChange={handleAdminSectionChange}
|
initialSection={adminSectionFromPath(window.location.pathname)}
|
||||||
onSettingsSaved={handleAdminPersistedSaved}
|
initialUserId={adminUserIdFromPath(window.location.pathname)}
|
||||||
onTariffsSaved={handleAdminPersistedSaved}
|
onSectionChange={handleAdminSectionChange}
|
||||||
onThemesSaved={handleAdminPersistedSaved}
|
onSettingsSaved={handleAdminPersistedSaved}
|
||||||
{brandTitle}
|
onTariffsSaved={handleAdminPersistedSaved}
|
||||||
{brand}
|
onThemesSaved={handleAdminPersistedSaved}
|
||||||
appFaviconUrl={CFG.faviconUrl}
|
{brandTitle}
|
||||||
appFaviconUseCustom={CFG.faviconUseCustom}
|
{brand}
|
||||||
appVersion={CFG.appVersion}
|
appFaviconUrl={CFG.faviconUrl}
|
||||||
appRepositoryUrl={CFG.appRepositoryUrl}
|
appFaviconUseCustom={CFG.faviconUseCustom}
|
||||||
{currentLang}
|
appVersion={CFG.appVersion}
|
||||||
{languageOptions}
|
appRepositoryUrl={CFG.appRepositoryUrl}
|
||||||
{languageBusy}
|
{currentLang}
|
||||||
onLanguageChange={accountStore.updateAccountLanguage}
|
{languageOptions}
|
||||||
{t}
|
{languageBusy}
|
||||||
/>
|
onLanguageChange={accountStore.updateAccountLanguage}
|
||||||
|
{t}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="loader">
|
||||||
|
<BrandMark {brand} size="md" />
|
||||||
|
<div>{adminBundleError ? t("wa_unavailable") : t("wa_loading")}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<WebAppShell
|
<WebAppShell
|
||||||
{screen}
|
{screen}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import AdminPanel from "./admin/AdminPanel.svelte";
|
||||||
|
import "./styles-admin.css";
|
||||||
|
|
||||||
|
window.SubscriptionWebAppAdminPanel = AdminPanel;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("subscription-webapp-admin-ready", {
|
||||||
|
detail: { AdminPanel },
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -33,6 +33,8 @@ export const DEV_MOCK = {
|
|||||||
faviconUrl: "",
|
faviconUrl: "",
|
||||||
faviconUseCustom: false,
|
faviconUseCustom: false,
|
||||||
apiBase: "/api",
|
apiBase: "/api",
|
||||||
|
adminJsAsset: "subscription_webapp_admin.js",
|
||||||
|
adminCssAsset: "subscription_webapp_admin.css",
|
||||||
supportUrl: "https://t.me/support",
|
supportUrl: "https://t.me/support",
|
||||||
privacyPolicyUrl: "https://example.com/privacy",
|
privacyPolicyUrl: "https://example.com/privacy",
|
||||||
userAgreementUrl: "https://example.com/agreement",
|
userAgreementUrl: "https://example.com/agreement",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@import "./styles/admin.css";
|
||||||
|
@import "./styles/admin-controls.css";
|
||||||
|
@import "./styles/admin-dialogs.css";
|
||||||
@@ -4,6 +4,3 @@
|
|||||||
@import "./styles/dialogs.css";
|
@import "./styles/dialogs.css";
|
||||||
@import "./styles/components.css";
|
@import "./styles/components.css";
|
||||||
@import "./styles/webapp.css";
|
@import "./styles/webapp.css";
|
||||||
@import "./styles/admin.css";
|
|
||||||
@import "./styles/admin-controls.css";
|
|
||||||
@import "./styles/admin-dialogs.css";
|
|
||||||
|
|||||||
+36
-31
@@ -8,39 +8,44 @@ import { defineConfig } from "vite";
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates");
|
const templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
resolve: {
|
const isAdminBuild = mode === "admin";
|
||||||
alias: {
|
const outputBase = isAdminBuild ? "subscription_webapp_admin" : "subscription_webapp";
|
||||||
$lib: path.resolve(__dirname, "src/lib"),
|
|
||||||
$components: path.resolve(__dirname, "src/lib/components"),
|
return {
|
||||||
},
|
resolve: {
|
||||||
},
|
alias: {
|
||||||
plugins: [tailwindcss(), svelte()],
|
$lib: path.resolve(__dirname, "src/lib"),
|
||||||
build: {
|
$components: path.resolve(__dirname, "src/lib/components"),
|
||||||
outDir: templateDir,
|
|
||||||
emptyOutDir: false,
|
|
||||||
minify: false,
|
|
||||||
sourcemap: false,
|
|
||||||
cssCodeSplit: false,
|
|
||||||
lib: {
|
|
||||||
entry: path.resolve(__dirname, "src/main.js"),
|
|
||||||
name: "SubscriptionWebApp",
|
|
||||||
formats: ["iife"],
|
|
||||||
fileName: () => "subscription_webapp.js",
|
|
||||||
cssFileName: "subscription_webapp",
|
|
||||||
},
|
|
||||||
rolldownOptions: {
|
|
||||||
checks: {
|
|
||||||
pluginTimings: false,
|
|
||||||
},
|
},
|
||||||
output: {
|
},
|
||||||
assetFileNames: (assetInfo) => {
|
plugins: [tailwindcss(), svelte()],
|
||||||
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
|
build: {
|
||||||
return "subscription_webapp.css";
|
outDir: templateDir,
|
||||||
}
|
emptyOutDir: false,
|
||||||
return "subscription_webapp.[name][extname]";
|
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]`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import gzip
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -447,6 +448,38 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"subscription_webapp.min.22222222.js",
|
"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):
|
async def test_js_asset_route_sets_immutable_cache_control_for_minified_asset(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
asset_dir = Path(tmpdir)
|
asset_dir = Path(tmpdir)
|
||||||
@@ -466,6 +499,71 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.text, "console.log('minified');")
|
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):
|
async def test_theme_css_asset_route_serves_file_from_configured_directory(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
themes_dir = Path(tmpdir)
|
themes_dir = Path(tmpdir)
|
||||||
@@ -487,8 +585,68 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.content_type, "text/css")
|
self.assertEqual(response.content_type, "text/css")
|
||||||
self.assertEqual(response.headers["Cache-Control"], "no-cache")
|
self.assertEqual(response.headers["Cache-Control"], "no-cache")
|
||||||
|
self.assertIn("ETag", response.headers)
|
||||||
self.assertIn("--bg: red", response.text)
|
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):
|
async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
request = SimpleNamespace(
|
request = SimpleNamespace(
|
||||||
@@ -542,8 +700,41 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.content_type, "image/png")
|
self.assertEqual(response.content_type, "image/png")
|
||||||
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
|
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
|
||||||
|
self.assertIn("ETag", response.headers)
|
||||||
self.assertEqual(response.body, b"png-bytes")
|
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):
|
async def test_theme_asset_route_uses_immutable_cache_for_versioned_assets(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
themes_dir = Path(tmpdir)
|
themes_dir = Path(tmpdir)
|
||||||
|
|||||||
@@ -63,9 +63,12 @@ class WebAppRouteContractTests(unittest.TestCase):
|
|||||||
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
|
("GET", "/webapp-uploaded-logo/{filename}"): "webapp_uploaded_logo_route",
|
||||||
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
|
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
|
||||||
("GET", "/subscription_webapp.css"): "css_asset_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", "/webapp-theme-css/{path}"): "theme_css_asset_route",
|
||||||
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
|
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
|
||||||
("GET", "/subscription_webapp.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/telegram/nonce"): "telegram_oauth_nonce_route",
|
||||||
("POST", "/api/auth/token"): "auth_token_route",
|
("POST", "/api/auth/token"): "auth_token_route",
|
||||||
("POST", "/api/auth/email/request"): "email_auth_request_route",
|
("POST", "/api/auth/email/request"): "email_auth_request_route",
|
||||||
|
|||||||
Reference in New Issue
Block a user