feat: add default webapp brand assets
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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 -->"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_.-]+}}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||