diff --git a/backend/bot/app/web/templates/default-brand/default-logo.webp b/backend/bot/app/web/templates/default-brand/default-logo.webp new file mode 100644 index 0000000..4d9ef0f Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/default-logo.webp differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/apple-touch-icon.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/apple-touch-icon.png new file mode 100644 index 0000000..1a200a2 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/apple-touch-icon.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/favicon.ico b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/favicon.ico new file mode 100644 index 0000000..37248a5 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/favicon.ico differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-16.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-16.png new file mode 100644 index 0000000..51d7215 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-16.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-180.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-180.png new file mode 100644 index 0000000..1a200a2 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-180.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-192.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-192.png new file mode 100644 index 0000000..5c0f281 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-192.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-32.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-32.png new file mode 100644 index 0000000..4029dec Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-32.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-48.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-48.png new file mode 100644 index 0000000..6b8b713 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-48.png differ diff --git a/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-512.png b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-512.png new file mode 100644 index 0000000..7673055 Binary files /dev/null and b/backend/bot/app/web/templates/default-brand/favicons/19b2a242e5b7bc2d/icon-512.png differ diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 743d78b..2cf590a 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -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_I18N_PLACEHOLDER = "" diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index 6bec6b2..8ce316b 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -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() diff --git a/backend/bot/app/web/webapp/routes.py b/backend/bot/app/web/webapp/routes.py index 978f40a..95151e3 100644 --- a/backend/bot/app/web/webapp/routes.py +++ b/backend/bot/app/web/webapp/routes.py @@ -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_.-]+}}", diff --git a/deploy/docker/frontend/nginx.conf b/deploy/docker/frontend/nginx.conf index 4fbc068..19332a1 100644 --- a/deploy/docker/frontend/nginx.conf +++ b/deploy/docker/frontend/nginx.conf @@ -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; diff --git a/frontend/src/lib/webapp/previewMock.js b/frontend/src/lib/webapp/previewMock.js index 849ebff..11dbd2b 100644 --- a/frontend/src/lib/webapp/previewMock.js +++ b/frontend/src/lib/webapp/previewMock.js @@ -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", diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index d1c5f7e..7828e71 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -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):