From efa6b4a2059f1da52ef435ec84bc548040dfa7e5 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 20 May 2026 08:13:28 +0200 Subject: [PATCH] fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Rekoo-PS confirmed the prior #521 fix landed on beta but reported the preview still shows the default "PicPeak" title — their brand is "arkan-studio". Root cause: that fix used Vite's build-time %VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built ghcr.io/the-luap/picpeak/frontend image can't override at build time without rebuilding, so they were stuck with whatever the upstream build baked in. Pivot to runtime substitution: the frontend container now reads BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts them into index.html. Change the values in .env, restart the frontend service, done — no rebuild required. Mechanics: - frontend/index.html: tokens are now ${BRAND_TITLE} / ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite unchanged into the built dist). - frontend/Dockerfile: install gettext (provides envsubst), snapshot /usr/share/nginx/html/index.html → index.html.tpl at build, install docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the immutable source — every container start re-renders index.html from .tpl, so restarts pick up new env values cleanly (no accidental "first-boot env stuck forever" trap). - frontend/docker-entrypoint.sh: applies defaults if env unset, runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION explicitly so /assets/*.js template literals aren't touched if anyone ever extends substitution to the bundle), execs nginx. - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no longer needed since substitution is fully runtime. - frontend/.env.example + .env.production.example: drop the VITE_DEFAULT_* docs (the vars no longer have effect). - docker-compose.yml + docker-compose.production.yml: pass BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service with sensible defaults so unconfigured installs work unchanged. - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment pointing at the social-preview use case. Verified end-to-end against the built image: - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs by Arkan Studio" → index.html serves Arkan Studio + og:title="Arkan Studio" + og:description correctly substituted. - .tpl preserves ${...} tokens so the next restart can re-substitute. - Bundle assets unaffected. - Defaults applied when env unset → PicPeak. Docs PR in picpeak-docs describes the two new env vars under "Social link preview fallback" in the environment-variables reference. Refs: #521 --- .env.example | 10 ++++++++ docker-compose.production.yml | 10 ++++++++ docker-compose.yml | 4 ++++ frontend/.env.example | 16 ------------- frontend/.env.production.example | 6 ----- frontend/Dockerfile | 22 +++++++++++++++--- frontend/docker-entrypoint.sh | 40 ++++++++++++++++++++++++++++++++ frontend/index.html | 28 +++++++++++++--------- frontend/vite.config.ts | 25 ++------------------ 9 files changed, 102 insertions(+), 59 deletions(-) create mode 100755 frontend/docker-entrypoint.sh diff --git a/.env.example b/.env.example index 8cc70e84..197fbc56 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,16 @@ EMAIL_FROM=noreply@yourdomain.com FRONTEND_URL=https://yourdomain.com ADMIN_URL=https://yourdomain.com +# Static HTML title + description used for social link previews when the +# fetcher doesn't trigger the per-event OG endpoint — most notably the +# WhatsApp Business API and various 3rd-party preview-service caches +# (#521). Set these to your brand so link previews aren't generic. +# Substituted into index.html at frontend-container start, so changes +# take effect on the next `docker compose up -d frontend` — no rebuild +# required. +BRAND_TITLE=PicPeak +BRAND_DESCRIPTION=Photo gallery shared with PicPeak. + # API URL for email assets (logos, images in notification emails) # This must be the publicly accessible URL where email recipients can load images. # If not set, defaults to http://localhost:3001 which will show broken images in emails. diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 27fcd9e5..f6560438 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -88,6 +88,16 @@ services: container_name: picpeak-frontend # Note: Pre-built frontend uses Nginx to proxy /api to backend:3001. # Prefer keeping API base as '/api' in builds to avoid CORS. + environment: + # Substituted into index.html at container start (see frontend/ + # docker-entrypoint.sh) so social link previews reaching the + # static SPA shell (WhatsApp Business API, Twilio, LinkPreview, + # etc. — see #521) show the configured brand instead of the + # generic "PicPeak" default. Defaults applied when unset; restart + # the frontend container after changing for the new title to + # take effect. + - BRAND_TITLE=${BRAND_TITLE:-PicPeak} + - BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.} ports: - "${FRONTEND_PORT:-3000}:80" networks: diff --git a/docker-compose.yml b/docker-compose.yml index c4984d1e..0ced821b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,10 @@ services: restart: unless-stopped environment: - NODE_ENV=${NODE_ENV:-production} + # Static social-preview brand (#521) — substituted into + # index.html at container start; see frontend/docker-entrypoint.sh. + - BRAND_TITLE=${BRAND_TITLE:-PicPeak} + - BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.} ports: - "${FRONTEND_PORT:-3000}:80" depends_on: diff --git a/frontend/.env.example b/frontend/.env.example index 33bc0f99..6000b0ec 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,19 +1,3 @@ -# Static HTML fallback for social-link previews (#521). -# -# Most link previews (WhatsApp, Facebook, Slack, etc.) hit the backend's -# per-event OG endpoint and get the actual event name + branding. Some -# third-party preview services and the WhatsApp Business API cache -# metadata with a non-crawler User-Agent and end up reading these static -# values instead. Set these to your brand so that fallback isn't generic -# "PicPeak - Photo Sharing Platform". -# -# These are baked into index.html at build time, so they take effect on -# the next `npm run build` / docker build. Live admin Branding settings -# do NOT propagate here — for that, use the per-event OG endpoint, which -# always serves the live branded preview. -VITE_DEFAULT_TITLE=PicPeak -VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak. - # Backend API URL # For local development with Docker: VITE_API_URL=http://localhost:3001/api diff --git a/frontend/.env.production.example b/frontend/.env.production.example index ddbdd7ea..8640bbd1 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -1,12 +1,6 @@ # Production Environment Configuration # When running behind a reverse proxy like Traefik, use relative URLs -# Static HTML fallback for social-link previews (#521). -# Override these with your brand so previews that hit the static -# index.html (vs the per-event OG endpoint) aren't generic. -VITE_DEFAULT_TITLE=PicPeak -VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak. - # Backend API URL # For production behind reverse proxy, use relative URL: VITE_API_URL=/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 41dbeff9..ceb52977 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -33,8 +33,11 @@ FROM nginx:1.28-alpine # Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs) RUN apk upgrade --no-cache -# Install runtime dependencies -RUN apk add --no-cache curl +# Install runtime dependencies. `gettext` provides envsubst, used by +# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime +# substitution into index.html (#521 — runtime fix for self-hosters +# on the pre-built GHCR image who can't override at build time). +RUN apk add --no-cache curl gettext # Remove default nginx config RUN rm -rf /etc/nginx/conf.d/* @@ -45,6 +48,16 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Copy built application from builder stage COPY --from=builder /app/dist /usr/share/nginx/html +# Snapshot index.html as a template so the entrypoint always renders +# from a known-good source — not from its own previous substitution. +# Container restarts can change BRAND_TITLE freely; the rendered file +# is recomputed from the .tpl each time. +RUN mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl + +# Runtime entrypoint that envsubsts the template and execs nginx +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + # Set permissions (nginx user already exists in nginx:alpine) RUN chown -R nginx:nginx /usr/share/nginx/html && \ chown -R nginx:nginx /var/cache/nginx && \ @@ -62,5 +75,8 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Switch to non-root user USER nginx -# Start nginx +# Start nginx via the entrypoint so each container start re-renders +# index.html from the template against the current BRAND_TITLE / +# BRAND_DESCRIPTION env vars (defaults applied when unset). +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100755 index 00000000..2218054d --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Frontend container entrypoint (#521). +# +# Renders /usr/share/nginx/html/index.html from a build-time .tpl +# snapshot, substituting BRAND_TITLE / BRAND_DESCRIPTION env vars into +# the static HTML head. This is what self-hosters running the pre-built +# GHCR image use to brand their link-preview fallback — see the matching +# comment in frontend/index.html for the three-path architecture +# (per-event OG endpoint, crawler-detected SPA shell, and this static +# fallback that catches WhatsApp Business / Twilio / LinkPreview). +# +# Re-runs on every container start. The .tpl is the immutable source so +# changing BRAND_TITLE in compose env and `docker compose up -d frontend` +# is enough — no rebuild required. +# +# Locked to BRAND_TITLE + BRAND_DESCRIPTION explicitly (rather than +# letting envsubst expand every ${...} it finds) so the JS bundle's +# template literals in /assets/*.js stay untouched if anyone ever +# accidentally points the substitution at them. +set -eu + +: "${BRAND_TITLE:=PicPeak}" +: "${BRAND_DESCRIPTION:=Photo gallery shared with PicPeak.}" + +export BRAND_TITLE BRAND_DESCRIPTION + +TEMPLATE=/usr/share/nginx/html/index.html.tpl +RENDERED=/usr/share/nginx/html/index.html + +if [ -f "$TEMPLATE" ]; then + envsubst '${BRAND_TITLE} ${BRAND_DESCRIPTION}' < "$TEMPLATE" > "$RENDERED" +else + # Template missing — image build skipped the .tpl rename for some + # reason. Don't crash: nginx can still serve whatever is at + # $RENDERED (probably the unsubstituted output of `npm run build`). + # Log loudly so it's visible during boot. + echo "[frontend-entrypoint] WARN: $TEMPLATE missing; serving $RENDERED as-is." >&2 +fi + +exec "$@" diff --git a/frontend/index.html b/frontend/index.html index 54c2d54c..9af5be8f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,20 +17,26 @@ title to "PicPeak - Photo Sharing Platform" left every such preview looking unbranded for self-hosted installs. - Self-hosters set VITE_DEFAULT_TITLE / VITE_DEFAULT_DESCRIPTION - at build time (see .env.example) to bake their brand into this - fallback. Default values keep the upstream-image behaviour for - anyone who doesn't override them. + ${BRAND_TITLE} / ${BRAND_DESCRIPTION} are replaced at *container + start* by the frontend image's docker-entrypoint.sh (envsubst on + an index.html.tpl snapshot taken at image build). That keeps the + tokens working even for self-hosters running the pre-built GHCR + image — set BRAND_TITLE in compose env and the next container + restart picks it up. No frontend rebuild needed. + + Vite dev server doesn't run the entrypoint, so dev mode shows the + literal tokens in the tab title — acceptable since dev sessions + don't care about social previews. --> - %VITE_DEFAULT_TITLE% - + ${BRAND_TITLE} + - - - + + + - - + +