From b2bbf7efb5ded63e8c7438d5288d351cb9124ce8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 19 May 2026 23:00:24 +0200 Subject: [PATCH 1/3] feat(lightbox): save photo to Photos app on mobile via Web Share (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Jasper2213 reported non-technical clients struggle to get downloaded photos into their Photos / Gallery app — current flow goes through the Files folder, requires unzipping for the bulk download, and is hard to explain over email. Browsers can't write directly to the OS Photos app (it's a protected location), but navigator.share({ files: [...] }) opens the native share sheet which on iOS includes "Save Image" and on Android includes "Save to Photos" / "Save image" — exactly the affordance non- technical users are looking for. Plumbed through three layers: 1. galleryService — new savePhotoToDevice(slug, photoId, filename). Fetches the photo blob, probes navigator.canShare({ files: [file] }) with a representative File (some browsers return true for empty files arrays even when they won't accept a non-empty one), and: - shares if supported, - falls back to the existing path otherwise. AbortError on share() means the user dismissed the sheet — that's a choice, not a failure, so no fallback. Any other error falls through to a regular download so the user still gets the file. Refactored the existing downloadPhoto to share the fetch + trigger helpers (no behaviour change for the other 3 callers; they keep the regular download path). 2. useGallery — new useSavePhotoToDevice() hook next to the existing useDownloadPhoto(). Onsuccess toast omitted because the share-sheet path doesn't finish from this code's perspective — the OS UI takes over and the user picks the destination, so "Photo downloaded" is misleading. Fallback path stays silent to keep the two flows symmetrical (the file appearing in Downloads is its own signal). 3. PhotoLightbox — swap the existing useDownloadPhoto call site to useSavePhotoToDevice. No UI change. Desktop unchanged. Other download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView bulk) still use useDownloadPhoto — scoping this PR to the lightbox download button per the discussion thread. Browser support: - iOS Safari 15+: Web Share Files → "Save Image" → Photos ✓ - Chrome Android: Web Share Files → "Save to Photos" / "Save" ✓ - Desktop Chrome: canShare returns false → regular download ✓ - Desktop Safari: canShare returns false → regular download ✓ - Firefox (any): no Web Share File support → regular download ✓ No new tests — the flow is browser-API-driven; jsdom doesn't model navigator.share or canShare, so a meaningful unit test would mostly exercise the mock rather than the contract. Verified the build is clean (tsc --noEmit + vite build both pass). Refs: #531 --- .../src/components/gallery/PhotoLightbox.tsx | 9 +- frontend/src/hooks/useGallery.ts | 29 +++++ frontend/src/services/gallery.service.ts | 107 ++++++++++++++---- 3 files changed, 121 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index f8c4cf66..51d8777e 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; -import { useDownloadPhoto } from '../../hooks/useGallery'; +import { useSavePhotoToDevice } from '../../hooks/useGallery'; import { AuthenticatedImage } from '../common'; import { PhotoFeedback } from './PhotoFeedback'; import { feedbackService } from '../../services/feedback.service'; @@ -99,7 +99,12 @@ export const PhotoLightbox: React.FC = ({ }, []); - const downloadPhotoMutation = useDownloadPhoto(); + // Save-aware download. On mobile (where Web Share + files is supported) + // this opens the OS share sheet so "Save to Photos" actually lands in + // the Photos/Gallery app — matters for non-technical clients who + // otherwise have to chain Files → unzip → save (#531). Desktop and + // unsupported browsers fall through to a regular . + const downloadPhotoMutation = useSavePhotoToDevice(); const currentPhoto = photos[currentIndex]; // DevTools protection - enabled by individual setting OR legacy protection level diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index 4f77c1ee..1e163ffc 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -65,6 +65,35 @@ export const useDownloadPhoto = () => { }); }; +// Save-aware download — opens the OS share sheet on mobile (so "Save to +// Photos" lands the file in the Photos/Gallery app instead of Files), +// falls back to a regular download on browsers without Web Share file +// support. See galleryService.savePhotoToDevice for the negotiation +// (#531). +// +// Toast omitted on success because the share-sheet path doesn't really +// finish from this code's perspective — the OS UI takes over and the +// user picks where it goes. Showing "Photo downloaded" before they've +// even picked is misleading. The fallback download path is also silent +// to keep the two paths symmetrical; the file appearing in Downloads +// is its own affordance. +export const useSavePhotoToDevice = () => { + return useMutation({ + mutationFn: ({ + slug, + photoId, + filename, + }: { + slug: string; + photoId: number; + filename: string; + }) => galleryService.savePhotoToDevice(slug, photoId, filename), + onError: () => { + toast.error('Failed to save photo'); + }, + }); +}; + export const useDownloadAllPhotos = () => { return useMutation({ mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) => diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index 061e3e52..2f47de0e 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -48,43 +48,106 @@ export const galleryService = { }; }, - // Download single photo - async downloadPhoto(slug: string, photoId: number, filename: string): Promise { - // Honour the server's Content-Disposition filename so the #493 - // "use original camera filename" toggle reaches disk for single - // downloads (it already worked for zips because those skip the - // `` attribute). Falls back to the caller-provided - // sanitized filename if the header is unreadable. - const downloadFromResponse = (response: { data: Blob; headers: Record }) => { + // Save single photo via the Web Share API on mobile, falling back to a + // regular browser download elsewhere (#531). + // + // On iOS Safari 15+ and Chrome Android the OS share sheet opened by + // navigator.share() includes "Save Image" / "Save to Photos", which + // is what non-technical clients actually want — straight into the + // Photos / Gallery app instead of the Files folder. Desktop browsers + // and Firefox don't implement Web Share File support, so they get the + // existing path (file lands in Downloads, same as before). + async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise { + const fetched = await this.fetchPhotoBlob(slug, photoId); + const resolvedFilename = fetched.serverFilename || filename; + + // canShare() returns false on browsers without Web Share file support + // (desktop, older Safari, all Firefox as of writing). Probe with a + // representative File so the negotiation is accurate — `canShare({ + // files: [] })` returns true on some browsers that don't actually + // accept files at share() time. + const file = new File([fetched.blob], resolvedFilename, { + type: fetched.blob.type || 'image/jpeg', + }); + const canShareFile = + typeof navigator !== 'undefined' && + typeof navigator.canShare === 'function' && + navigator.canShare({ files: [file] }); + + if (canShareFile) { + try { + await navigator.share({ files: [file], title: resolvedFilename }); + return; + } catch (err) { + // AbortError = user dismissed the share sheet. Don't fall back — + // they made a choice. Any other failure (NotAllowedError, + // DataError, etc.) is unexpected; surface a download instead so + // the user still gets the file. + if ((err as DOMException)?.name === 'AbortError') return; + } + } + + this.triggerBrowserDownload(fetched.blob, resolvedFilename); + }, + + // Fetch the photo as a Blob + the server-suggested filename, falling + // back to the view endpoint when the original isn't available. Shared + // between the regular download flow and the Web Share path (#531). + // The server's Content-Disposition is the source of truth for the + // filename (#493 — "use original camera filename" toggle reaches disk + // through this header). + async fetchPhotoBlob( + slug: string, + photoId: number, + ): Promise<{ blob: Blob; serverFilename: string | null }> { + const readResponse = (response: { data: Blob; headers: Record }) => { const headerName = response.headers['content-disposition'] || response.headers['Content-Disposition']; - const serverFilename = parseContentDispositionFilename(headerName); - const url = window.URL.createObjectURL(new Blob([response.data])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', serverFilename || filename); - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); + return { + blob: response.data, + serverFilename: parseContentDispositionFilename(headerName), + }; }; try { const response = await api.get(`/gallery/${slug}/download/${photoId}`, { responseType: 'blob', }); - downloadFromResponse(response); + return readResponse(response); } catch { - // Fallback: use the view endpoint if direct download fails (e.g., missing original). - // The view endpoint doesn't emit a download-oriented Content-Disposition, - // so we expect the caller-supplied filename to win here. + // Fallback: view endpoint when /download isn't available (e.g. + // the original is missing and only a derivative remains). The + // view endpoint doesn't emit a download-oriented Content-Disposition, + // so serverFilename will be null and the caller's name wins. const response = await api.get(`/gallery/${slug}/photo/${photoId}`, { responseType: 'blob', }); - downloadFromResponse(response); + return readResponse(response); } }, + // Trigger a regular browser download via a transient + // anchor. Extracted from downloadPhoto so the share-fallback path + // can reuse it without re-fetching the blob. + triggerBrowserDownload(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // Download single photo — kept as the canonical name for the existing + // grid + lightbox-action callers that haven't been migrated to the + // share-aware savePhotoToDevice path yet. + async downloadPhoto(slug: string, photoId: number, filename: string): Promise { + const fetched = await this.fetchPhotoBlob(slug, photoId); + this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename); + }, + // Download all photos as ZIP // When a pre-generated zip is available, use native browser download (Content-Length → progress bar). // Otherwise fall back to blob download. From 53139b8cb87e0669fe38089f38848a59ce3cbb28 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 20 May 2026 08:03:32 +0200 Subject: [PATCH 2/3] fix(lightbox): pan zoomed image with single-finger touch on mobile (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Rekoo-PS reported zoom on mobile only shows the centre crop — the image zooms but you can't pan around to see other parts. Single-finger touch was being routed through the carousel-swipe branch which is gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed the touch hit no handler at all. Desktop has the equivalent path via handleMouseDown / handleMouseMove (line 364), which is why this only manifests on mobile. Add a single-finger pan branch to the touch handlers that mirrors the mouse path: - handleTouchStart: when zoom > 1 and one finger, record dragStart relative to the existing dragOffset (so subsequent moves continue from where the last pan left off, not from origin). - handleTouchMove: when isDragging + zoom > 1 + one finger, update dragOffset from touch position. - handleTouchEnd: clear the isDragging flag (offset persists so the image stays where the user left it). Also fix a latent bug surfaced while reading the pinch-zoom path: when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the photo sat off-centre at natural zoom. Re-centre in handleTouchMove when newZoom drops to <=1 with a non-zero offset. Carousel swipe stays disabled when zoomed (existing behaviour). Mouse path untouched. Pinch-to-zoom path untouched. Refs: #532 --- .../src/components/gallery/PhotoLightbox.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 51d8777e..504735dd 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -411,6 +411,16 @@ export const PhotoLightbox: React.FC = ({ setDragX(0); } } + } else if (e.touches.length === 1 && zoom > 1) { + // Single-finger pan when zoomed in (#532). Mirrors the desktop + // handleMouseDown path so mobile users can drag a zoomed image + // around instead of being stuck looking at the centre crop. + // Carousel swipe is disabled in this branch — when zoom > 1 the + // gesture has to mean "pan", not "next photo", or zoomed nav + // becomes unusable. + const t = e.touches[0]; + setIsDragging(true); + setDragStart({ x: t.clientX - dragOffset.x, y: t.clientY - dragOffset.y }); } else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) { const t = e.touches[0]; swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; @@ -432,6 +442,24 @@ export const PhotoLightbox: React.FC = ({ const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); setTouchDistance(newDistance); + // Pinch-out back down to 1.0 has to re-centre the image — without + // this the previous pan offset persists and the photo sits off- + // centre at the natural zoom level (#532 follow-on). + if (newZoom <= 1 && (dragOffset.x !== 0 || dragOffset.y !== 0)) { + setDragOffset({ x: 0, y: 0 }); + } + return; + } + + if (isDragging && zoom > 1 && e.touches.length === 1) { + // Single-finger pan when zoomed (#532). Touch counterpart to + // handleMouseMove. Same dragOffset state so the transform on the + // stays consistent across input modalities. + const t = e.touches[0]; + setDragOffset({ + x: t.clientX - dragStart.x, + y: t.clientY - dragStart.y, + }); return; } @@ -459,6 +487,10 @@ export const PhotoLightbox: React.FC = ({ const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); + // Release single-finger pan state (#532). The pan offset itself + // persists so the image stays where the user left it — only the + // "actively dragging" flag clears. + if (isDragging) setIsDragging(false); const start = swipeStartRef.current; if (phase === 'dragging' && start && e.changedTouches.length > 0) { const t = e.changedTouches[0]; From efa6b4a2059f1da52ef435ec84bc548040dfa7e5 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 20 May 2026 08:13:28 +0200 Subject: [PATCH 3/3] 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} + - - - + + + - - + +