fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)

@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 <title>Arkan Studio</title>
    + 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 → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
This commit is contained in:
Paul Nothaft
2026-05-20 08:13:28 +02:00
parent 53139b8cb8
commit efa6b4a205
9 changed files with 102 additions and 59 deletions
+10
View File
@@ -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.
+10
View File
@@ -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:
+4
View File
@@ -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:
-16
View File
@@ -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
-6
View File
@@ -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
+19 -3
View File
@@ -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;"]
+40
View File
@@ -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 "$@"
+17 -11
View File
@@ -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.
-->
<title>%VITE_DEFAULT_TITLE%</title>
<meta name="description" content="%VITE_DEFAULT_DESCRIPTION%" />
<title>${BRAND_TITLE}</title>
<meta name="description" content="${BRAND_DESCRIPTION}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="%VITE_DEFAULT_TITLE%" />
<meta property="og:title" content="%VITE_DEFAULT_TITLE%" />
<meta property="og:description" content="%VITE_DEFAULT_DESCRIPTION%" />
<meta property="og:site_name" content="${BRAND_TITLE}" />
<meta property="og:title" content="${BRAND_TITLE}" />
<meta property="og:description" content="${BRAND_DESCRIPTION}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="%VITE_DEFAULT_TITLE%" />
<meta name="twitter:description" content="%VITE_DEFAULT_DESCRIPTION%" />
<meta name="twitter:title" content="${BRAND_TITLE}" />
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
<!-- Pre-React theme bootstrap (#358).
The browser may paint the very first frame before our inline
+2 -23
View File
@@ -1,34 +1,13 @@
/// <reference types="vitest" />
// @ts-nocheck
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import type { UserConfig as VitestUserConfig } from 'vitest/config'
// Inject defaults for the %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION%
// placeholders in index.html when the env vars aren't set (#521). Without
// this, Vite would leave the literal "%VITE_DEFAULT_TITLE%" string in the
// built HTML, breaking the link-preview fallback we're trying to create.
//
// Self-hosters override by exporting the env vars at build time
// (typical Docker build pattern: --build-arg VITE_DEFAULT_TITLE="My Brand").
function htmlTitleDefaults(mode: string) {
const env = loadEnv(mode, process.cwd(), 'VITE_')
const title = env.VITE_DEFAULT_TITLE || 'PicPeak'
const description = env.VITE_DEFAULT_DESCRIPTION || 'Photo gallery shared with PicPeak.'
return {
name: 'html-title-defaults',
transformIndexHtml(html: string) {
return html
.replaceAll('%VITE_DEFAULT_TITLE%', title)
.replaceAll('%VITE_DEFAULT_DESCRIPTION%', description)
},
}
}
// https://vite.dev/config/
const config: VitestUserConfig = {
plugins: [react(), htmlTitleDefaults(process.env.NODE_ENV || 'production')],
plugins: [react()],
build: {
rollupOptions: {
output: {