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.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
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
+125
-31
@@ -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}
|
||||
<AdminPanel
|
||||
{api}
|
||||
onClose={closeAdminPanel}
|
||||
onToast={(text) => 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}
|
||||
<svelte:component
|
||||
this={AdminPanelComponent}
|
||||
{api}
|
||||
onClose={closeAdminPanel}
|
||||
onToast={(text) => 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}
|
||||
<div class="loader">
|
||||
<BrandMark {brand} size="md" />
|
||||
<div>{adminBundleError ? t("wa_unavailable") : t("wa_loading")}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<WebAppShell
|
||||
{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: "",
|
||||
faviconUseCustom: false,
|
||||
apiBase: "/api",
|
||||
adminJsAsset: "subscription_webapp_admin.js",
|
||||
adminCssAsset: "subscription_webapp_admin.css",
|
||||
supportUrl: "https://t.me/support",
|
||||
privacyPolicyUrl: "https://example.com/privacy",
|
||||
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/components.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 templateDir = path.resolve(__dirname, "../backend/bot/app/web/templates");
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, "src/lib"),
|
||||
$components: path.resolve(__dirname, "src/lib/components"),
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss(), svelte()],
|
||||
build: {
|
||||
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,
|
||||
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]`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user