feat: add default webapp brand assets

This commit is contained in:
3252a8
2026-05-27 07:07:31 +03:00
parent a92ad32b23
commit f77a6ea46d
15 changed files with 140 additions and 8 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+8
View File
@@ -71,6 +71,14 @@ WEBAPP_UPLOADED_LOGO_DIR = WEBAPP_LOGO_CACHE_DIR / "uploads"
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo" WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons" WEBAPP_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
WEBAPP_FAVICON_PATH = "/webapp-favicon" WEBAPP_FAVICON_PATH = "/webapp-favicon"
WEBAPP_DEFAULT_BRAND_DIR = ASSET_DIR / "default-brand"
WEBAPP_DEFAULT_LOGO_FILE = WEBAPP_DEFAULT_BRAND_DIR / "default-logo.webp"
WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
WEBAPP_DEFAULT_FAVICON_URL = (
f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
)
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji" WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->" WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->" WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
+41 -3
View File
@@ -205,7 +205,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip() raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
if not raw_logo_url: if not raw_logo_url:
return "" return WEBAPP_DEFAULT_LOGO_PATH
parsed_logo_url = urlsplit(raw_logo_url) parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https": if parsed_logo_url.scheme == "https":
@@ -215,7 +215,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return raw_logo_url return raw_logo_url
if raw_logo_url.startswith("/"): if raw_logo_url.startswith("/"):
return raw_logo_url return raw_logo_url
return "" return WEBAPP_DEFAULT_LOGO_PATH
def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str: def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
@@ -227,7 +227,9 @@ def _resolve_webapp_favicon_url(settings: Settings, logo_url: str = "") -> str:
resolved = _resolve_webapp_asset_url(raw_logo_favicon_url) resolved = _resolve_webapp_asset_url(raw_logo_favicon_url)
if resolved: if resolved:
return resolved return resolved
return logo_url or "" if logo_url and logo_url != WEBAPP_DEFAULT_LOGO_PATH:
return logo_url
return WEBAPP_DEFAULT_FAVICON_URL
def _resolve_webapp_asset_url(raw_url: str) -> str: def _resolve_webapp_asset_url(raw_url: str) -> str:
@@ -364,6 +366,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
return _uploaded_webapp_logo_response(filename) return _uploaded_webapp_logo_response(filename)
async def webapp_default_logo_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
response = _webapp_default_brand_file_response(WEBAPP_DEFAULT_LOGO_FILE, "image/webp")
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
async def webapp_favicon_route(request: web.Request) -> web.Response: async def webapp_favicon_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED: if not settings.WEBAPP_ENABLED:
@@ -451,6 +463,9 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
): ):
raise web.HTTPNotFound(text="webapp_favicon_not_found") raise web.HTTPNotFound(text="webapp_favicon_not_found")
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
return _webapp_default_favicon_file_response(filename)
root = WEBAPP_FAVICON_DIR.expanduser().resolve() root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve() path = (root / digest / filename).resolve()
try: try:
@@ -477,6 +492,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.Response:
return response return response
def _webapp_default_favicon_file_response(filename: str) -> web.Response:
path = WEBAPP_DEFAULT_FAVICON_DIR / filename
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(path.suffix.lower())
if not content_type:
raise web.HTTPNotFound(text="webapp_favicon_not_found")
response = _webapp_default_brand_file_response(path, content_type)
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Response:
try:
body = _read_template_binary_cached(path)
except OSError:
raise web.HTTPNotFound(text="webapp_default_brand_not_found") from None
if not body or len(body) > WEBAPP_LOGO_MAX_BYTES:
raise web.HTTPNotFound(text="webapp_default_brand_not_found")
return web.Response(body=body, content_type=content_type)
async def webapp_animated_emoji_route(request: web.Request) -> web.Response: async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
codepoints = str(request.match_info.get("codepoints") or "").strip().lower() codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
ext = str(request.match_info.get("ext") or "").strip().lower() ext = str(request.match_info.get("ext") or "").strip().lower()
+1
View File
@@ -35,6 +35,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route) app.router.add_get("/apple-touch-icon-precomposed.png", webapp_current_favicon_route)
app.router.add_get("/icon-192.png", webapp_current_favicon_route) app.router.add_get("/icon-192.png", webapp_current_favicon_route)
app.router.add_get("/icon-512.png", webapp_current_favicon_route) app.router.add_get("/icon-512.png", webapp_current_favicon_route)
app.router.add_get(WEBAPP_DEFAULT_LOGO_PATH, webapp_default_logo_route)
app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route)
app.router.add_get( app.router.add_get(
rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}", rf"{WEBAPP_UPLOADED_LOGO_PATH}/{{filename:[A-Za-z0-9_.-]+}}",
+9
View File
@@ -59,6 +59,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location = /webapp-default-logo.webp {
proxy_pass http://backend:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/(favicon\.ico|apple-touch-icon(?:-precomposed)?\.png|icon-(?:192|512)\.png)$ { location ~ ^/(favicon\.ico|apple-touch-icon(?:-precomposed)?\.png|icon-(?:192|512)\.png)$ {
proxy_pass http://backend:8081; proxy_pass http://backend:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
+2 -2
View File
@@ -202,11 +202,11 @@ export const DEV_MOCK = {
config: { config: {
title: "/minishop", title: "/minishop",
primaryColor: "#00fe7a", primaryColor: "#00fe7a",
logoUrl: "", logoUrl: "/webapp-default-logo.webp",
logoUseEmoji: false, logoUseEmoji: false,
logoEmoji: "🫥", logoEmoji: "🫥",
logoEmojiFont: "system", logoEmojiFont: "system",
faviconUrl: "", faviconUrl: "/webapp-favicon/19b2a242e5b7bc2d/icon-180.png",
faviconUseCustom: false, faviconUseCustom: false,
apiBase: "/api", apiBase: "/api",
adminJsAsset: "subscription_webapp_admin.js", adminJsAsset: "subscription_webapp_admin.js",
+79 -3
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from PIL import Image from PIL import Image, ImageOps
from bot.app.web import subscription_webapp from bot.app.web import subscription_webapp
from bot.app.web.admin_api_impl import themes as admin_themes from bot.app.web.admin_api_impl import themes as admin_themes
@@ -110,6 +110,14 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"/webapp-uploaded-logo/logo-abcdef1234567890.png", "/webapp-uploaded-logo/logo-abcdef1234567890.png",
) )
def test_default_webapp_logo_is_used_without_admin_upload(self):
settings = SimpleNamespace(WEBAPP_LOGO_USE_EMOJI=False, WEBAPP_LOGO_URL="")
self.assertEqual(
subscription_webapp._resolve_webapp_logo_url(settings),
subscription_webapp.WEBAPP_DEFAULT_LOGO_PATH,
)
async def test_webapp_logo_route_serves_configured_uploaded_logo(self): async def test_webapp_logo_route_serves_configured_uploaded_logo(self):
settings = SimpleNamespace( settings = SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=False, WEBAPP_LOGO_USE_EMOJI=False,
@@ -159,14 +167,17 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"/webapp-favicon/1111111111111111/icon-180.png", "/webapp-favicon/1111111111111111/icon-180.png",
) )
def test_logo_generated_favicon_is_not_used_without_logo(self): def test_default_favicon_is_used_without_logo_favicon(self):
settings = SimpleNamespace( settings = SimpleNamespace(
WEBAPP_FAVICON_USE_CUSTOM=False, WEBAPP_FAVICON_USE_CUSTOM=False,
WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png", WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/icon-180.png",
WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png", WEBAPP_LOGO_FAVICON_URL="/webapp-favicon/1111111111111111/icon-180.png",
) )
self.assertEqual(subscription_webapp._resolve_webapp_favicon_url(settings, ""), "") self.assertEqual(
subscription_webapp._resolve_webapp_favicon_url(settings, ""),
subscription_webapp.WEBAPP_DEFAULT_FAVICON_URL,
)
def test_favicon_head_markup_includes_touch_icon(self): def test_favicon_head_markup_includes_touch_icon(self):
markup = subscription_webapp._favicon_head_markup( markup = subscription_webapp._favicon_head_markup(
@@ -236,6 +247,70 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.content_type, "image/png") self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.body, b"touch-icon") self.assertEqual(response.body, b"touch-icon")
async def test_current_favicon_alias_serves_default_icon_when_unconfigured(self):
settings = SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_LOGO_URL="",
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_FAVICON_USE_CUSTOM=False,
WEBAPP_FAVICON_URL="",
WEBAPP_LOGO_FAVICON_URL="",
)
request = SimpleNamespace(app={"settings": settings}, path="/icon-192.png")
response = await webapp_assets.webapp_current_favicon_route(request)
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "image/png")
self.assertGreater(len(response.body), 0)
async def test_default_logo_route_serves_bundled_logo(self):
settings = SimpleNamespace(WEBAPP_ENABLED=True)
request = SimpleNamespace(app={"settings": settings})
response = await webapp_assets.webapp_default_logo_route(request)
self.assertEqual(response.status, 200)
self.assertEqual(response.content_type, "image/webp")
self.assertGreater(len(response.body), 0)
def test_default_favicon_set_uses_middle_animation_frame(self):
def visible_bytes(image: Image.Image) -> bytes:
rgba = bytearray(image.convert("RGBA").tobytes())
for offset in range(0, len(rgba), 4):
if rgba[offset + 3] == 0:
rgba[offset : offset + 3] = b"\x00\x00\x00"
return bytes(rgba)
with Image.open(subscription_webapp.WEBAPP_DEFAULT_LOGO_FILE) as source:
frame_count = getattr(source, "n_frames", 1)
self.assertGreater(frame_count, 1)
durations = []
for frame_index in range(frame_count):
source.seek(frame_index)
durations.append(int(source.info.get("duration") or 0))
middle_index = frame_count // 2
if any(durations):
midpoint = sum(durations) / 2
elapsed = 0
for frame_index, duration in enumerate(durations):
elapsed += duration
if elapsed >= midpoint:
middle_index = frame_index
break
source.seek(0)
first_frame = ImageOps.exif_transpose(source).convert("RGBA")
source.seek(middle_index)
middle_frame = ImageOps.exif_transpose(source).convert("RGBA")
with Image.open(subscription_webapp.WEBAPP_DEFAULT_FAVICON_DIR / "icon-512.png") as icon:
rendered_icon = icon.convert("RGBA")
self.assertEqual(rendered_icon.size, (512, 512))
self.assertEqual(visible_bytes(rendered_icon), visible_bytes(middle_frame))
self.assertNotEqual(visible_bytes(rendered_icon), visible_bytes(first_frame))
def test_static_webapp_template_exposes_ios_icon_aliases(self): def test_static_webapp_template_exposes_ios_icon_aliases(self):
template = Path("backend/bot/app/web/templates/subscription_webapp.html").read_text( template = Path("backend/bot/app/web/templates/subscription_webapp.html").read_text(
encoding="utf-8" encoding="utf-8"
@@ -250,6 +325,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("apple-touch-icon", nginx_conf) self.assertIn("apple-touch-icon", nginx_conf)
self.assertIn("favicon\\.ico", nginx_conf) self.assertIn("favicon\\.ico", nginx_conf)
self.assertIn("/webapp-default-logo.webp", nginx_conf)
self.assertIn("proxy_pass http://backend:8081;", nginx_conf) self.assertIn("proxy_pass http://backend:8081;", nginx_conf)
def test_prune_unused_appearance_assets_keeps_only_referenced_logo_and_favicons(self): def test_prune_unused_appearance_assets_keeps_only_referenced_logo_and_favicons(self):