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_FAVICON_DIR = WEBAPP_LOGO_CACHE_DIR / "favicons"
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_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_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()
if not raw_logo_url:
return ""
return WEBAPP_DEFAULT_LOGO_PATH
parsed_logo_url = urlsplit(raw_logo_url)
if parsed_logo_url.scheme == "https":
@@ -215,7 +215,7 @@ def _resolve_webapp_logo_url(settings: Settings) -> str:
return raw_logo_url
if raw_logo_url.startswith("/"):
return raw_logo_url
return ""
return WEBAPP_DEFAULT_LOGO_PATH
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)
if 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:
@@ -364,6 +366,16 @@ async def webapp_uploaded_logo_route(request: web.Request) -> web.Response:
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:
settings: Settings = request.app["settings"]
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")
if digest == WEBAPP_DEFAULT_FAVICON_DIGEST:
return _webapp_default_favicon_file_response(filename)
root = WEBAPP_FAVICON_DIR.expanduser().resolve()
path = (root / digest / filename).resolve()
try:
@@ -477,6 +492,29 @@ def _webapp_favicon_file_response(digest: str, filename: str) -> web.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:
codepoints = str(request.match_info.get("codepoints") 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("/icon-192.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(
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;
}
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)$ {
proxy_pass http://backend:8081;
proxy_http_version 1.1;
+2 -2
View File
@@ -202,11 +202,11 @@ export const DEV_MOCK = {
config: {
title: "/minishop",
primaryColor: "#00fe7a",
logoUrl: "",
logoUrl: "/webapp-default-logo.webp",
logoUseEmoji: false,
logoEmoji: "🫥",
logoEmojiFont: "system",
faviconUrl: "",
faviconUrl: "/webapp-favicon/19b2a242e5b7bc2d/icon-180.png",
faviconUseCustom: false,
apiBase: "/api",
adminJsAsset: "subscription_webapp_admin.js",
+79 -3
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from types import SimpleNamespace
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.admin_api_impl import themes as admin_themes
@@ -110,6 +110,14 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"/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):
settings = SimpleNamespace(
WEBAPP_LOGO_USE_EMOJI=False,
@@ -159,14 +167,17 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
"/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(
WEBAPP_FAVICON_USE_CUSTOM=False,
WEBAPP_FAVICON_URL="/webapp-favicon/abcdef1234567890/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):
markup = subscription_webapp._favicon_head_markup(
@@ -236,6 +247,70 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.content_type, "image/png")
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):
template = Path("backend/bot/app/web/templates/subscription_webapp.html").read_text(
encoding="utf-8"
@@ -250,6 +325,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("apple-touch-icon", 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)
def test_prune_unused_appearance_assets_keeps_only_referenced_logo_and_favicons(self):