From db3e3270f311c31d2e6bfad7c0a5e5c204a2fedc Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:35:32 +0200 Subject: [PATCH] fix(branding): serve favicon via backend route so Safari picks it up Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and is unreliable about honouring JS-injected , so an admin-set favicon never showed there (index.html only ships /favicon-32x32.png; a bare /favicon.ico 404'd). - Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve the configured branding_favicon_url (redirect to its /uploads path or the absolute URL), falling back to the bundled /favicon-32x32.png. - nginx: exact-match (=) locations proxy those paths to the backend, winning over the static-asset regex that previously served them from the build dir. - DynamicFavicon also emits an apple-touch-icon link (belt-and-braces). Requires a frontend image REBUILD (nginx.conf change) in addition to backend. --- backend/server.js | 28 +++++++++++++++ frontend/nginx.conf | 34 +++++++++++++++++++ .../src/components/common/DynamicFavicon.tsx | 10 +++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index afb377e7..cfd08923 100644 --- a/backend/server.js +++ b/backend/server.js @@ -559,6 +559,34 @@ app.get('/robots.txt', async (req, res) => { } }); +// Dynamic favicon endpoints. Browsers — notably Safari — request +// /favicon.ico and /apple-touch-icon*.png directly at the site root and are +// unreliable about honouring JS-injected tags. Serving the +// admin's configured branding favicon here makes it work without client-side +// JS (and survive aggressive favicon caches). Falls back to the bundled asset +// shipped with the frontend build when no custom favicon is set. +app.get( + ['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'], + async (req, res) => { + try { + const { getAppSetting } = require('./src/utils/appSettings'); + const raw = await getAppSetting('branding_favicon_url', null); + const url = (raw && String(raw).trim()) || null; + if (url) { + // Absolute URL → redirect as-is. Otherwise it's an /uploads path the + // backend already serves with the correct content-type + headers. + const target = /^https?:\/\//i.test(url) + ? url + : (url.startsWith('/') ? url : `/uploads/${url.replace(/^uploads\//, '')}`); + return res.redirect(302, target); + } + } catch (error) { + logger.warn('Favicon lookup failed; serving bundled default', { error: error.message }); + } + return res.redirect(302, '/favicon-32x32.png'); + } +); + // Health check endpoint. `pid` + `uptime` let monitors (and the local E2E // watchdog) detect a silent process restart between two checks. app.get('/health', async (req, res) => { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1c8dc721..45175a03 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -184,6 +184,40 @@ server { proxy_set_header X-Forwarded-Proto $real_proto; } + # Dynamic favicon / apple-touch-icon served by backend (resolves the + # admin-configured branding favicon, falls back to the bundled asset). + # Exact-match (=) wins over the static-asset regex below, so these reach + # the backend instead of the build dir. Browsers (especially Safari) + # request these at the site root regardless of any JS-injected + # . + location = /favicon.ico { + set $backend_upstream backend; + proxy_pass http://$backend_upstream:3000/favicon.ico; + 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 $real_proto; + } + location = /apple-touch-icon.png { + set $backend_upstream backend; + proxy_pass http://$backend_upstream:3000/apple-touch-icon.png; + 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 $real_proto; + } + location = /apple-touch-icon-precomposed.png { + set $backend_upstream backend; + proxy_pass http://$backend_upstream:3000/apple-touch-icon-precomposed.png; + 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 $real_proto; + } + # Delegate root requests to backend for public landing page handling location = / { # Use variable to force DNS resolution per request (required for Docker Swarm) diff --git a/frontend/src/components/common/DynamicFavicon.tsx b/frontend/src/components/common/DynamicFavicon.tsx index 1cade38f..c5686106 100644 --- a/frontend/src/components/common/DynamicFavicon.tsx +++ b/frontend/src/components/common/DynamicFavicon.tsx @@ -35,8 +35,16 @@ export const DynamicFavicon: React.FC = () => { link.rel = 'icon'; if (ext && typeByExt[ext]) link.type = typeByExt[ext]; link.href = href; - document.head.appendChild(link); + + // Safari uses apple-touch-icon for bookmarks / home-screen and is + // unreliable about JS-injected rel="icon". The backend /favicon.ico + + // /apple-touch-icon routes are the primary mechanism; this is + // belt-and-braces for browsers that do read the DOM link. + const appleLink = document.createElement('link'); + appleLink.rel = 'apple-touch-icon'; + appleLink.href = href; + document.head.appendChild(appleLink); } }, [settings?.branding_favicon_url]);