feat(setup): configure the public address and SMTP in the wizard, not .env (#1104)

* feat(setup): configure the public address and SMTP in the wizard, not .env

A fresh install could not configure its own public address. `general_site_url`
and the `email_configs` row already existed as admin settings, but nothing
could reach them:

- docker-compose.yml injected FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
  and Dockerfile.aio baked in ENV FRONTEND_URL=http://localhost:3000, so
  getFrontendBaseUrl() returned on its first branch every time and the setting
  was never read. .env.example shipped the same value as an uncommented
  placeholder for FRONTEND_URL / ADMIN_URL / API_URL.
- the wizard never asked for the address at all, and skipped its whole config
  step unless a CRM-ish feature was selected — so a gallery-only install was
  also never offered SMTP, despite gallery links, guest invites and expiry
  warnings all going out through email_configs.
- eleven call sites read process.env.FRONTEND_URL directly rather than the
  resolver, three of them defaulting to placeholder hosts that reached real
  recipients: https://app.example.com in payment-reminder emails, localhost:3005
  in admin invitation emails, https://app.example.com in dev template previews.

Stop injecting a default anywhere, and resolve the origin instead:
FRONTEND_URL -> general_site_url -> the origin the request arrived on ->
whichever exists -> ''. A loopback candidate is treated as unconfigured so the
installs that already have http://localhost:3000 baked into their environment
self-heal; the same guard previously lived inline in routes/gallery.js for the
slideshow QR (#848) and is now shared. The empty return is preserved because
shareLinkService and the SSO redirects in routes/auth rely on it to emit
relative urls — callers needing an absolute url use getAbsoluteFrontendUrl(),
which still ends at http://localhost:3000.

The wizard now persists window.location.origin right after the admin account is
created, so an install that skips the rest still has a usable origin for
background jobs that have no request to derive one from, and offers it as an
editable "Public address" field. Settings -> General shows the field read-only
when FRONTEND_URL pins it, instead of silently ignoring edits.

Also drop the `|| 'mailhog'` fallback when seeding email_configs: that host only
exists in the dev compose profile (which does not even start by default), so a
fresh install came up with a live config pointing nowhere while the wizard
showed empty SMTP fields. With no row, blank fields are the truth and
emailProcessor logs "No email configuration found". Developers set
SMTP_HOST=mailhog explicitly.

backend/src/services/emailService.js is deleted: nothing in backend/ references
it, and it was the only consumer of the SMTP_* variables, which misrepresented
how mail is configured.

Refs #705

* fix(setup): keep FRONTEND_URL ahead of ADMIN_URL/APP_URL when resolving links

The previous commit routed two call sites through the resolver but put the
site-specific variable FIRST, silently reversing precedence:

  userManagementService  was: FRONTEND_URL || ADMIN_URL || localhost:3005
                    became: ADMIN_URL || resolver
  adminEvents/crud       was: FRONTEND_URL || APP_URL || ''
                    became: APP_URL || resolver

An install with both variables set would have flipped which one won. Call the
resolver first instead — it starts with FRONTEND_URL, so the original relative
order is preserved and only the final fallback changes: localhost:3005 (not
even the frontend's port) and '' (a relative link inside an email) both become
the resolved origin.

Refs #705

* fix(setup): unpin loopback FRONTEND_URL, keep ADMIN_URL/APP_URL reachable

Review feedback on #1104.

isEnvPinned() reported ANY FRONTEND_URL as authoritative, including the
loopback values getFrontendBaseUrl() deliberately demotes. An install
upgrading with the old compose default FRONTEND_URL=http://localhost:3000
therefore resolved its origin from general_site_url correctly, but got the
Site URL field rendered read-only in Settings and skipped by the wizard's
seeding - locking the exact operators this change exists to unblock out of
configuring a public address anywhere. The predicate now mirrors the
resolver, and the derived general_site_url_effective the General tab reads
comes from the same helper instead of re-normalising process.env inline.

APP_URL and ADMIN_URL had become dead code: getFrontendBaseUrl() only
returns falsy when NOTHING is configured, so `|| process.env.ADMIN_URL`
after it never ran once a site URL existed - which after this PR is the
normal case. A split-origin install pointing ADMIN_URL at a separate admin
host got invite links on the public gallery origin instead. They are now
passed as an explicit `override` that resolves directly below FRONTEND_URL,
preserving the historic FRONTEND_URL-before-ADMIN_URL order while beating
the database- and request-derived fallbacks.

general_site_url now feeds the CORS allowlist and the
Access-Control-Allow-Origin header, not just email links, so a schemeless
value is an allowlist entry no browser origin can match. Validate it
server-side in PUT /general (isURL with require_protocol, require_tld off
so LAN/NAS installs on http://nas:3000 still work) and client-side in both
surfaces that write it - type="url" never fires in either, since neither
input sits inside a form.

Two more wizard fixes: the General tab no longer reposts general_site_url
while it is env-pinned, because the field then holds the effective env
value rather than the stored one and the round-trip read as a change to a
protected key, 403ing a settings.edit-without-settings.domains admin on an
unrelated save. And SetupConfigStep validates the From address before
posting - /admin/email/config rejects a blank one, which used to surface as
a generic warning while the wizard advanced from its finally block anyway,
discarding every SMTP value the user had typed, password included. A failed
save now keeps them on the step.

* fix(setup): surface a rejected public address instead of swallowing it

Review round 2 follow-up on #1104, pushed onto the branch.

saveSiteUrl() caught and discarded every error. That was defensible before
round 2 added a server-side URL check, but PUT /general can now answer 400 —
and the two validators disagreed:

  http://my_nas.local      client: accepted   server: rejected
  http://foo_bar:3000      client: accepted   server: rejected

validate() let those through, the 400 was swallowed, `failed` stayed false and
onDone() ran. The operator finished the wizard believing the public address was
stored when nothing had been. That is the silent misconfiguration this whole
change exists to remove, landing on the LAN and NAS installs it targets.

Three parts:

- saveSiteUrl() throws. finish() resolves it before anything else is posted and
  puts the message on the address field rather than the generic "some settings
  could not be saved" warning. Skip for now still always leaves, by contract,
  but warns instead of dropping the value in silence.
- allow_underscores on the server check, for the same reason require_tld is
  off: browsers resolve http://my_nas.local and the client accepts it, so
  rejecting it server-side only produced the mismatch above. Both validators
  now agree across the LAN/NAS, IDN, bare-IP and scheme-less cases.
- LOOPBACK_BASE_RE anchors its host token. Bare prefix matching also demoted
  https://localhost-nas.example.com, and now that this predicate gates the
  whole resolver rather than just the slideshow QR, being demoted means a
  configured address is silently ignored. 127. stays a bare prefix on purpose:
  all of 127.0.0.0/8 is loopback.

Resolver suite 31 passing, up from 26. Mutation-checked: restoring the
unanchored regex fails the three new host-boundary cases.

* fix(settings): don't lock the General tab on a site URL nobody typed

Review follow-up on #1104, pushed onto the branch.

general_site_url was free-text until this PR added a server-side check, so an
upgraded install can hold something schemeless that predates it. The tab
flagged that on load, and `disabled={!!siteUrlError}` then killed Save for
EVERY General setting.

An admin holding settings.edit but not settings.domains could not clear it
either: correcting the address is a change to a protected key and 403s. The
tab has no permission gating, so that role was simply locked out of the tab
with no self-service way back.

That is the same role adminSettings.js:85-95 documents the no-op round-trip
allowance for. The allowance only helps if the request is made, and this
blocked it in the browser first.

Validation now waits until the field is actually edited, and an unchanged
value is dropped from the payload rather than reposted — matching what the
env-pinned case already does one line above, and for the same reason.

  stored value invalid, untouched   Save works, key not sent
  edited to something unusable      Save blocked
  edited to a usable absolute url   saved

Four tests, first coverage for this feature. Mutation-checked: removing the
dirty gate fails the untouched-value case.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Luca
2026-08-21 12:54:14 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 7583b4f6b0
commit 9431b9f094
28 changed files with 984 additions and 177 deletions
+25 -13
View File
@@ -73,21 +73,32 @@ DB_NAME=picpeak_prod
#[email protected] #[email protected]
#ADMIN_PASSWORD=your_secure_admin_password_here #ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration # Email Configuration — OPTIONAL, and normally left alone.
# SMTP is configured in the setup wizard / Settings -> Email and stored in the
# database (email_configs); that is what the mail queue actually sends with.
# These variables are a legacy path kept for config-as-code deployments: when
# SMTP_HOST is set, the initial migration seeds the database row from it.
# Developers running the `dev` compose profile want SMTP_HOST=mailhog here so
# that seed points at the mailhog container.
# For Gmail: use app-specific password # For Gmail: use app-specific password
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key # For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
SMTP_HOST=smtp.gmail.com #SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 #SMTP_PORT=587
SMTP_SECURE=false #SMTP_SECURE=false
SMTP_USER=[email protected] #SMTP_USER=[email protected]
SMTP_PASS=your-app-specific-password #SMTP_PASS=your-app-specific-password
EMAIL_FROM=[email protected] #EMAIL_FROM=[email protected]
# Application URLs # Application URLs — OPTIONAL. Leave unset for the normal install.
# The public origin is captured by the setup wizard (it proposes the address
# you opened the browser at) and stored as the `general_site_url` setting, so
# you can change it later in Settings -> General without touching this file.
# Setting FRONTEND_URL here OVERRIDES that setting and makes the field
# read-only in the admin UI - use it only for config-as-code deployments.
# Use full origin with scheme, no trailing slash. # Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin. # Admin UI is served by the frontend at /admin.
FRONTEND_URL=https://yourdomain.com #FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com #ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the # Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the # fetcher doesn't trigger the per-event OG endpoint — most notably the
@@ -100,9 +111,10 @@ BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak. BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails) # API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images. # OPTIONAL: when unset this is derived from the resolved public origin + /api,
# If not set, defaults to http://localhost:3001 which will show broken images in emails. # so the wizard's answer covers it. Set it only for split-origin deployments
API_URL=https://yourdomain.com/api # where the API lives on a different host than the gallery.
#API_URL=https://yourdomain.com/api
# Frontend API base # Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'. # For pre-built images and production behind a reverse proxy, keep '/api'.
+9 -9
View File
@@ -113,15 +113,15 @@ ENV DATA_ROOT=/data \
LOG_DIR=/data/logs \ LOG_DIR=/data/logs \
BACKUP_DIR=/data/backup BACKUP_DIR=/data/backup
# Share links are absolute only when a base URL is known: getFrontendBaseUrl() # FRONTEND_URL is deliberately NOT set here (#705). It used to default to
# reads FRONTEND_URL, falls back to the general_site_url setting, and otherwise # http://localhost:3000 so share links would not come out relative, but a
# returns "" — which makes share_url come out as a bare "/gallery/<slug>/<token>" # baked-in value OVERRIDES the general_site_url setting the setup wizard
# in API responses, QR codes and emails. docker-compose.yml defaults this to # writes — so a single-container install could never configure its own public
# http://localhost:3000, but the documented one-liner for this image passes only # address, and the Settings field would show as env-pinned for everyone.
# JWT_SECRET, so without a default here every fresh single-container install # getFrontendBaseUrl() now resolves the setting, then the origin the request
# would hand out unusable links. Same default as compose; override with # arrived on, and getAbsoluteFrontendUrl() still ends at http://localhost:3000,
# -e FRONTEND_URL=https://photos.example.com, or set the site URL in Settings. # so links stay absolute without pinning anything. Override with
ENV FRONTEND_URL=http://localhost:3000 # -e FRONTEND_URL=https://photos.example.com for config-as-code deployments.
# /app/storage is a second entrance to the same volume. The business-document # /app/storage is a second entrance to the same volume. The business-document
# writers (quoteService, invoice sending/reminders, contract signatures) build # writers (quoteService, invoice sending/reminders, contract signatures) build
+10 -3
View File
@@ -146,11 +146,18 @@ Generated on: ${new Date().toISOString()}
console.log('Default email templates created'); console.log('Default email templates created');
} }
// Create default email config if none exists // Seed an email config ONLY when the environment actually supplies a host
// (#705). This used to fall back to `mailhog`, the dev compose service, so
// every fresh install came up with a LIVE config pointing at a host that
// does not exist outside the dev stack — the setup wizard then showed empty
// SMTP fields (reading as "nothing configured") while mail silently failed.
// With no row at all, emailProcessor logs "No email configuration found"
// and the wizard's blank fields are the truth. The dev stack keeps mailhog
// by setting SMTP_HOST explicitly in docker-compose.yml.
const emailConfig = await knex('email_configs').first(); const emailConfig = await knex('email_configs').first();
if (!emailConfig) { if (!emailConfig && process.env.SMTP_HOST) {
await knex('email_configs').insert({ await knex('email_configs').insert({
smtp_host: process.env.SMTP_HOST || 'mailhog', smtp_host: process.env.SMTP_HOST,
smtp_port: process.env.SMTP_PORT || 1025, smtp_port: process.env.SMTP_PORT || 1025,
smtp_secure: process.env.SMTP_SECURE === 'true', smtp_secure: process.env.SMTP_SECURE === 'true',
smtp_user: process.env.SMTP_USER || '', smtp_user: process.env.SMTP_USER || '',
+18 -3
View File
@@ -69,6 +69,11 @@ const compression = require('compression');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const { initializeDatabase, db } = require('./src/database/db'); const { initializeDatabase, db } = require('./src/database/db');
const {
getFrontendBaseUrlSync,
getAbsoluteFrontendUrl,
primeSiteUrlCache,
} = require('./src/utils/frontendUrl');
const { startFileWatcher } = require('./src/services/fileWatcher'); const { startFileWatcher } = require('./src/services/fileWatcher');
const { startExpirationChecker } = require('./src/services/expirationChecker'); const { startExpirationChecker } = require('./src/services/expirationChecker');
const { startTransferCleanup } = require('./src/services/transferCleanupService'); const { startTransferCleanup } = require('./src/services/transferCleanupService');
@@ -212,8 +217,12 @@ app.use((req, res, next) => {
// CORS configuration (apply only to API routes) // CORS configuration (apply only to API routes)
const corsOptions = { const corsOptions = {
origin: function (origin, callback) { origin: function (origin, callback) {
// getFrontendBaseUrlSync() resolves FRONTEND_URL, else the configured
// general_site_url (#705) — without it, an install that leaves the
// environment untouched and answers the setup wizard instead would have
// its own public origin missing from the allowlist.
const allowedOrigins = [ const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005', getFrontendBaseUrlSync() || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005' process.env.ADMIN_URL || 'http://localhost:3005'
]; ];
@@ -499,7 +508,7 @@ app.use('/api/admin', sessionTimeoutMiddleware);
const setCorsHeaders = (req, res, next) => { const setCorsHeaders = (req, res, next) => {
const origin = req.headers.origin; const origin = req.headers.origin;
const staticAllowedOrigins = [ const staticAllowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005', getFrontendBaseUrlSync() || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005' process.env.ADMIN_URL || 'http://localhost:3005'
]; ];
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@@ -637,7 +646,7 @@ app.get('/s/:shortSlug', async (req, res) => {
// social platforms cache OG by URL, and the short URL is the // social platforms cache OG by URL, and the short URL is the
// one operators actually share, so that's the cache key we // one operators actually share, so that's the cache key we
// want them to stick with. // want them to stick with.
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, ''); const base = await getAbsoluteFrontendUrl(req);
meta.url = `${base}/s/${row.short_slug}`; meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300'); res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8'); res.set('Content-Type', 'text/html; charset=utf-8');
@@ -1016,6 +1025,12 @@ async function startServer() {
// Initialize database // Initialize database
await initializeDatabase(); await initializeDatabase();
// Warm the public-origin cache so the SYNCHRONOUS resolver (CORS headers,
// secureImageMiddleware) can see the general_site_url setting. Best-effort:
// the async resolver reads through on its own, and a cold cache only means
// falling back to the environment.
await primeSiteUrlCache().catch(() => {});
// Initialize storage backend (local fs or S3) — fail fast on misconfig // Initialize storage backend (local fs or S3) — fail fast on misconfig
const { initStorage } = require('./src/services/storage'); const { initStorage } = require('./src/services/storage');
await initStorage(); await initStorage();
+247
View File
@@ -0,0 +1,247 @@
/**
* Unit tests for the public-origin resolver (#705).
*
* The zero-touch install depends on this precedence being exactly:
* 1. FRONTEND_URL, when it is not loopback
* 2. a purpose-specific override the caller passes (ADMIN_URL, APP_URL)
* 3. the `general_site_url` setting written by the setup wizard
* 4. the origin the request arrived on
* 5. whichever of those exists at all (a genuine localhost install)
* 6. '' — so callers that build RELATIVE urls keep doing so
*
* The loopback demotion in (1) matters because docker-compose used to inject
* FRONTEND_URL=http://localhost:3000 unconditionally: taking that literally
* sends every gallery link, QR code and reminder email to the RECIPIENT's own
* machine. The empty return in (5) is load-bearing too — shareLinkService and
* the SSO redirects in routes/auth treat it as "emit a relative url".
*/
// `mock`-prefixed so jest's out-of-scope guard allows the factory to close
// over it; the same fn is reused across resetModules() so assertions on call
// counts survive a reload.
const mockDb = jest.fn();
jest.mock('../database/db', () => ({ db: mockDb }));
const db = mockDb;
// Mimic knex's builder for: db('app_settings').where(...).select(...).first()
const settingRow = (value) => ({
where: () => ({ select: () => ({ first: async () => (value === undefined ? undefined : { setting_value: JSON.stringify(value) }) }) }),
});
const fakeReq = (host, protocol = 'http') => ({ protocol, get: (h) => (h === 'host' ? host : undefined) });
// resetModules() clears the resolver's cached setting between cases; the
// jest.mock registration above survives it.
const loadModule = () => {
jest.resetModules();
return require('../utils/frontendUrl');
};
describe('getFrontendBaseUrl precedence', () => {
beforeEach(() => {
delete process.env.FRONTEND_URL;
delete process.env.API_URL;
db.mockReset();
});
it('prefers a non-loopback FRONTEND_URL and never touches the database', async () => {
process.env.FRONTEND_URL = 'https://pinned.example.com';
db.mockImplementation(() => settingRow('https://setting.example.com'));
const m = loadModule();
expect(await m.getFrontendBaseUrl()).toBe('https://pinned.example.com');
expect(db).not.toHaveBeenCalled();
});
it('demotes a loopback FRONTEND_URL in favour of the configured setting', async () => {
process.env.FRONTEND_URL = 'http://localhost:3000';
db.mockImplementation(() => settingRow('http://192.168.1.50:3000'));
const m = loadModule();
expect(await m.getFrontendBaseUrl()).toBe('http://192.168.1.50:3000');
});
it('uses the wizard setting when the environment is untouched', async () => {
db.mockImplementation(() => settingRow('https://gallery.example.com'));
const m = loadModule();
expect(await m.getFrontendBaseUrl()).toBe('https://gallery.example.com');
});
it('falls back to the origin the request arrived on', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl(fakeReq('192.168.1.77:3000'))).toBe('http://192.168.1.77:3000');
});
it('honours X-Forwarded-Proto via req.protocol', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl(fakeReq('gallery.example.com', 'https'))).toBe('https://gallery.example.com');
});
it('returns empty when nothing is configured, so callers can go relative', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl()).toBe('');
});
it('still resolves a genuine localhost install rather than returning empty', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl(fakeReq('localhost:3000'))).toBe('http://localhost:3000');
});
it('strips trailing slashes', async () => {
process.env.FRONTEND_URL = 'https://pinned.example.com///';
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl()).toBe('https://pinned.example.com');
});
});
describe('getAbsoluteFrontendUrl', () => {
beforeEach(() => {
delete process.env.FRONTEND_URL;
delete process.env.API_URL;
db.mockReset();
});
it('ends at localhost:3000 when nothing is configured and there is no request', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getAbsoluteFrontendUrl()).toBe('http://localhost:3000');
});
});
describe('getApiBaseUrl', () => {
beforeEach(() => {
delete process.env.FRONTEND_URL;
delete process.env.API_URL;
db.mockReset();
});
it('prefers an explicit API_URL for split-origin deployments', async () => {
process.env.API_URL = 'https://api.example.com';
db.mockImplementation(() => settingRow('https://gallery.example.com'));
const m = loadModule();
expect(await m.getApiBaseUrl()).toBe('https://api.example.com');
});
it('otherwise derives /api from the resolved origin', async () => {
db.mockImplementation(() => settingRow('https://gallery.example.com'));
const m = loadModule();
expect(await m.getApiBaseUrl()).toBe('https://gallery.example.com/api');
});
});
describe('purpose-specific env override (ADMIN_URL / APP_URL)', () => {
beforeEach(() => { delete process.env.FRONTEND_URL; db.mockReset(); });
it('beats the general_site_url setting, so a split-origin admin host wins', async () => {
db.mockImplementation(() => settingRow('https://gallery.example.com'));
const m = loadModule();
expect(await m.getFrontendBaseUrl(null, { override: 'https://admin.example.com' }))
.toBe('https://admin.example.com');
});
it('beats the request origin too', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl(fakeReq('gallery.example.com', 'https'), { override: 'https://admin.example.com' }))
.toBe('https://admin.example.com');
});
it('still sits below FRONTEND_URL, preserving the historic order', async () => {
process.env.FRONTEND_URL = 'https://pinned.example.com';
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getFrontendBaseUrl(null, { override: 'https://admin.example.com' }))
.toBe('https://pinned.example.com');
});
it('is demoted when it is loopback, like every other candidate', async () => {
db.mockImplementation(() => settingRow('https://gallery.example.com'));
const m = loadModule();
expect(await m.getFrontendBaseUrl(null, { override: 'http://localhost:3005' }))
.toBe('https://gallery.example.com');
});
it('is used by getAbsoluteFrontendUrl ahead of the terminal fallback', async () => {
db.mockImplementation(() => settingRow(undefined));
const m = loadModule();
expect(await m.getAbsoluteFrontendUrl(null, { override: 'https://admin.example.com' }))
.toBe('https://admin.example.com');
});
});
describe('isEnvPinned', () => {
beforeEach(() => { delete process.env.FRONTEND_URL; db.mockReset(); });
it('reports the environment override so the admin UI can go read-only', async () => {
db.mockImplementation(() => settingRow(undefined));
let m = loadModule();
expect(m.isEnvPinned()).toBe(false);
process.env.FRONTEND_URL = 'https://pinned.example.com';
m = loadModule();
expect(m.isEnvPinned()).toBe(true);
});
// The upgrade case this whole PR exists for: an install still carrying the
// old compose default. The resolver demotes it, so the admin UI must NOT
// lock the Site URL field — otherwise that operator can never configure a
// public address anywhere (#1104).
it('does NOT report a loopback FRONTEND_URL as pinned', () => {
process.env.FRONTEND_URL = 'http://localhost:3000';
const m = loadModule();
expect(m.isEnvPinned()).toBe(false);
expect(m.envPinnedBase()).toBe('');
});
it('exposes the effective pinned value, normalised', () => {
process.env.FRONTEND_URL = 'https://pinned.example.com/// ';
const m = loadModule();
expect(m.envPinnedBase()).toBe('https://pinned.example.com');
});
});
describe('isLoopbackBase', () => {
it.each([
['http://localhost:3000', true],
['http://127.0.0.1:8080', true],
['http://0.0.0.0:3000', true],
['http://[::1]:3000', true],
['http://localhost', true],
['http://localhost/gallery', true],
['http://192.168.1.50:3000', false],
['https://gallery.example.com', false],
// The host token must end at a boundary. These are real public hosts that
// merely START with a loopback name — demoting them would silently ignore
// the address the operator configured, since this predicate now gates the
// whole resolver rather than just the slideshow QR.
['https://localhost-nas.example.com', false],
['https://localhostings.io', false],
['http://0.0.0.0.nip.io', false],
['', false],
])('%s → %s', (url, expected) => {
const m = loadModule();
expect(m.isLoopbackBase(url)).toBe(expected);
});
});
@@ -2,6 +2,7 @@ const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getFrontendBaseUrlSync } = require('../utils/frontendUrl');
/** /**
* Enhanced secure image middleware with comprehensive protection * Enhanced secure image middleware with comprehensive protection
@@ -272,7 +273,10 @@ class SecureImageMiddleware {
'X-Download-Policy': 'restricted', 'X-Download-Policy': 'restricted',
// CORS restrictions // CORS restrictions
'Access-Control-Allow-Origin': process.env.FRONTEND_URL || '*', // Deliberately NOT derived from the request: reflecting the caller's
// Origin would defeat the allowlist. Env, else the configured
// general_site_url (cached), else the previous wildcard behaviour.
'Access-Control-Allow-Origin': getFrontendBaseUrlSync() || '*',
'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Max-Age': '3600' 'Access-Control-Max-Age': '3600'
+2 -2
View File
@@ -128,7 +128,7 @@ router.get(
}) })
); );
const FRONTEND_URL_FALLBACK = 'https://app.example.com'; const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test'); const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test');
function fakeMoney(major, currency, locale = 'de') { function fakeMoney(major, currency, locale = 'de') {
@@ -440,7 +440,7 @@ router.post(
throw new AppError(`Template "${req.body.templateKey}" not seeded yet — run migrations`, 409, 'TEMPLATE_MISSING'); throw new AppError(`Template "${req.body.templateKey}" not seeded yet — run migrations`, 409, 'TEMPLATE_MISSING');
} }
const frontendUrl = (process.env.FRONTEND_URL || FRONTEND_URL_FALLBACK).replace(/\/$/, ''); const frontendUrl = await getAbsoluteFrontendUrl(req);
const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl); const payload = await buildPayloadFor(req.body.templateKey, req.admin.id, frontendUrl);
await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload); await emailProcessor.queueEmail(null, admin.email, req.body.templateKey, payload);
+7 -2
View File
@@ -26,7 +26,7 @@ const { hasColumnCached } = require('../../utils/schemaCache');
const { requireEventOwnership } = require('../../middleware/ownership'); const { requireEventOwnership } = require('../../middleware/ownership');
const { getAppSetting } = require('../../utils/appSettings'); const { getAppSetting } = require('../../utils/appSettings');
const { clampIntOrUndefined } = require('../../utils/numericHelpers'); const { clampIntOrUndefined } = require('../../utils/numericHelpers');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService'); const downloadZipService = require('../../services/downloadZipService');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
@@ -576,7 +576,12 @@ module.exports = (router) => {
// Include client access info in email when enabled (#172) // Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) { if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first(); const createdEvent = await db('events').where('id', eventId).first();
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || ''; // Same FRONTEND_URL-before-APP_URL order as before: APP_URL is
// passed as the override so it still outranks the general_site_url
// setting and the request origin. Chaining it after the resolver
// would make it dead code, because the resolver only returns falsy
// when NOTHING is configured (#1104).
const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL });
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`; emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password; emailData.client_password = client_password;
} }
+5 -4
View File
@@ -9,8 +9,7 @@ const { requireEventOwnership } = require('../middleware/ownership');
const feedbackService = require('../services/feedbackService'); const feedbackService = require('../services/feedbackService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers'); const { errorResponse } = require('../utils/routeHelpers');
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
const FRONTEND_URL = process.env.FRONTEND_URL || '';
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Helpers // Helpers
@@ -185,10 +184,11 @@ router.get(
) )
.orderBy('guest_invites.created_at', 'desc'); .orderBy('guest_invites.created_at', 'desc');
const baseUrl = await getAbsoluteFrontendUrl(req);
const invites = rows.map((r) => ({ const invites = rows.map((r) => ({
id: r.id, id: r.id,
token: r.token, token: r.token,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`, url: `${baseUrl}/gallery/${event.slug}?invite=${r.token}`,
created_at: r.created_at, created_at: r.created_at,
redeemed_at: r.redeemed_at, redeemed_at: r.redeemed_at,
revoked_at: r.revoked_at, revoked_at: r.revoked_at,
@@ -261,11 +261,12 @@ router.post(
); );
const event = await db('events').where({ id: eventId }).first(); const event = await db('events').where({ id: eventId }).first();
const baseUrl = await getAbsoluteFrontendUrl(req);
res.json({ res.json({
invite: { invite: {
id: inviteId, id: inviteId,
token: inviteToken, token: inviteToken,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`, url: `${baseUrl}/gallery/${event.slug}?invite=${inviteToken}`,
status: 'pending', status: 'pending',
guest: { id: guestId, name, email: email || null }, guest: { id: guestId, name, email: email || null },
}, },
+63 -1
View File
@@ -3,6 +3,7 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const validator = require('validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
@@ -21,6 +22,7 @@ const {
const { sanitizeCss } = require('../utils/cssSanitizer'); const { sanitizeCss } = require('../utils/cssSanitizer');
const { upsertAppSetting } = require('../utils/appSettings'); const { upsertAppSetting } = require('../utils/appSettings');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService'); const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { invalidateSiteUrlCache, isEnvPinned, envPinnedBase } = require('../utils/frontendUrl');
const { resetSecurityConfigCache } = require('../utils/authSecurity'); const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers'); const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -57,7 +59,12 @@ const RESERVED_SETTING_KEYS = [
// would do neither, leaving galleries handing out archives at the old size. // would do neither, leaving galleries handing out archives at the old size.
const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key) const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key)
|| key.startsWith('oidc_') || key.startsWith('oidc_')
|| key.startsWith('download_'); || key.startsWith('download_')
// Derived, read-only fields the GET response adds for the General tab
// (#705). They are computed from the environment, never stored, so a
// round-trip of the GET payload must not create phantom setting rows.
|| key === 'general_site_url_env_pinned'
|| key === 'general_site_url_effective';
const stripReservedSettingKeys = (settings) => { const stripReservedSettingKeys = (settings) => {
for (const key of Object.keys(settings)) { for (const key of Object.keys(settings)) {
if (isReservedSettingKey(key)) delete settings[key]; if (isReservedSettingKey(key)) delete settings[key];
@@ -240,6 +247,14 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
// any settings.view holder; setup_token is the first-run bootstrap secret. // any settings.view holder; setup_token is the first-run bootstrap secret.
stripReservedSettingKeys(settingsObject); stripReservedSettingKeys(settingsObject);
// Surface whether FRONTEND_URL pins the public origin (#705). The env var
// OVERRIDES general_site_url, so without this the General tab would offer
// an editable field whose value is silently ignored at runtime.
settingsObject.general_site_url_env_pinned = isEnvPinned();
if (settingsObject.general_site_url_env_pinned) {
settingsObject.general_site_url_effective = envPinnedBase();
}
// Mask sensitive secrets before sending to client // Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) { if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••'; settingsObject.security_recaptcha_secret_key = '••••••••';
@@ -833,6 +848,15 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
// any settings.view holder; setup_token is the first-run bootstrap secret. // any settings.view holder; setup_token is the first-run bootstrap secret.
stripReservedSettingKeys(settingsObject); stripReservedSettingKeys(settingsObject);
// Same derived read-only fields as GET / (#705) — the General tab reads
// through this typed route, so the env-pinned hint must be here too.
if (type === 'general') {
settingsObject.general_site_url_env_pinned = isEnvPinned();
if (settingsObject.general_site_url_env_pinned) {
settingsObject.general_site_url_effective = envPinnedBase();
}
}
// Mask sensitive secrets before sending to client // Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) { if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••'; settingsObject.security_recaptcha_secret_key = '••••••••';
@@ -1368,6 +1392,38 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
// rejectUnauthorizedProtectedKeys (403s when a protected key is denied). // rejectUnauthorizedProtectedKeys (403s when a protected key is denied).
if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return; if (await rejectUnauthorizedProtectedKeys(settings, req, res)) return;
// The public origin is no longer just an email link: it feeds the CORS
// allowlist (server.js) and the Access-Control-Allow-Origin header
// (secureImageMiddleware) since #705. A schemeless value like
// "gallery.example.com" therefore produces both links that don't resolve
// AND an allowlist entry no browser origin can ever match, so validate it
// server-side rather than trusting the input type (#1104). require_tld is
// off on purpose: LAN and NAS installs legitimately run on http://nas:3000
// or a bare IP. Same validator as oidc_issuer_url above.
if (Object.prototype.hasOwnProperty.call(settings, 'general_site_url')) {
const siteUrl = typeof settings.general_site_url === 'string'
? settings.general_site_url.trim().replace(/\/+$/, '')
: '';
// allow_underscores for the same reason require_tld is off: this has to
// accept the addresses LAN and NAS installs actually run on. Browsers
// resolve http://my_nas.local happily and the client-side check accepts
// it, so rejecting it here only produced a mismatch between the two
// validators — and the wizard has no way to show a 400 it did not
// predict (#1104 review round 2).
const looksValid = validator.isURL(siteUrl, {
protocols: ['http', 'https'],
require_protocol: true,
require_tld: false,
allow_underscores: true,
});
if (siteUrl && !looksValid) {
return res.status(400).json({
error: 'general_site_url must be an absolute http(s) URL, for example https://gallery.example.com'
});
}
settings.general_site_url = siteUrl;
}
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
@@ -1463,6 +1519,12 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache(); clearShareLinkSettingsCache();
} }
// The public origin is cached (it now sits in per-request CORS paths and
// in a synchronous accessor); drop it immediately on write so a corrected
// site URL takes effect without waiting out the TTL.
if (Object.prototype.hasOwnProperty.call(settings, 'general_site_url')) {
invalidateSiteUrlCache();
}
// Toggling the original-filenames setting (#493) requires busting the // Toggling the original-filenames setting (#493) requires busting the
// per-event pre-generated zips so the next download-all rebuilds with the // per-event pre-generated zips so the next download-all rebuilds with the
// new entry names. Single-photo downloads pick up the change as soon as // new entry names. Single-photo downloads pick up the change as soon as
+3 -1
View File
@@ -501,7 +501,9 @@ const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at }
// origin guests can reach — prefer that whenever the configured base is // origin guests can reach — prefer that whenever the configured base is
// missing or loopback. trust proxy is configured, so req.protocol respects // missing or loopback. trust proxy is configured, so req.protocol respects
// X-Forwarded-Proto behind the standard reverse-proxy setups. // X-Forwarded-Proto behind the standard reverse-proxy setups.
const QR_LOCAL_BASE_RE = /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i; // Centralised in utils/frontendUrl (#705) so the QR path and the public-origin
// resolver agree on what counts as a non-shareable base.
const QR_LOCAL_BASE_RE = { test: (v) => require('../utils/frontendUrl').isLoopbackBase(v) };
const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i; const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
async function slideshowQrDataUrl(event, req) { async function slideshowQrDataUrl(event, req) {
try { try {
-66
View File
@@ -1,66 +0,0 @@
const nodemailer = require('nodemailer');
const { db } = require('../database/db');
const { emailTemplates } = require('./emailTemplates');
const logger = require('../utils/logger');
// Create transporter
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
async function sendEmail(to, type, data) {
try {
const template = emailTemplates[type](data);
const info = await transporter.sendMail({
from: process.env.EMAIL_FROM,
to: to,
subject: template.subject,
html: template.html,
text: template.text
});
logger.info(`Email sent: ${info.messageId}`);
return info;
} catch (error) {
logger.error('Error sending email:', error);
throw error;
}
}
// Process email queue
async function processEmailQueue() {
const pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.limit(10);
for (const email of pendingEmails) {
try {
const emailData = JSON.parse(email.email_data);
await sendEmail(email.recipient_email, email.email_type, emailData);
await db('email_queue').where('id', email.id).update({
status: 'sent',
sent_at: new Date()
});
} catch (error) {
await db('email_queue').where('id', email.id).update({
retry_count: email.retry_count + 1,
error_message: error.message
});
}
}
}
// Start email queue processor
// DISABLED: Using emailProcessor.js instead to prevent duplicate connections
// setInterval(processEmailQueue, 60000); // Process every minute
module.exports = { sendEmail, processEmailQueue };
+4 -3
View File
@@ -2,6 +2,7 @@ const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { ensureThumbnail } = require('./imageProcessor'); const { ensureThumbnail } = require('./imageProcessor');
const { getStorage } = require('./storage'); const { getStorage } = require('./storage');
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
const SOCIAL_CRAWLER_PATTERNS = [ const SOCIAL_CRAWLER_PATTERNS = [
/facebookexternalhit/i, /facebookexternalhit/i,
@@ -148,8 +149,8 @@ function absoluteUrl(maybeRelative, base) {
} }
} }
function frontendBase() { async function frontendBase() {
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, ''); return getAbsoluteFrontendUrl();
} }
// Render the event date for the OG preview card respecting the // Render the event date for the OG preview card respecting the
@@ -171,7 +172,7 @@ async function formatEventDate(value) {
async function buildOgMetadata(slug, requestPath) { async function buildOgMetadata(slug, requestPath) {
const event = await resolveSlug(slug); const event = await resolveSlug(slug);
const branding = await fetchBranding(); const branding = await fetchBranding();
const base = frontendBase(); const base = await frontendBase();
const siteName = branding.companyName || 'PicPeak'; const siteName = branding.companyName || 'PicPeak';
const logoUrl = absoluteUrl(branding.logoUrl, base) || `${base}/picpeak-logo-transparent.png`; const logoUrl = absoluteUrl(branding.logoUrl, base) || `${base}/picpeak-logo-transparent.png`;
+7 -2
View File
@@ -7,6 +7,7 @@ const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings'); const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors'); const { AppError } = require('../../utils/errors');
const { formatShortDate } = require('../../utils/dateFormatter'); const { formatShortDate } = require('../../utils/dateFormatter');
const { getFrontendBaseUrl, DEFAULT_ABSOLUTE_BASE } = require('../../utils/frontendUrl');
const emailProcessor = require('../emailProcessor'); const emailProcessor = require('../emailProcessor');
const { ensureInt } = require('../../utils/numericHelpers'); const { ensureInt } = require('../../utils/numericHelpers');
const { formatMajor } = require('./helpers'); const { formatMajor } = require('./helpers');
@@ -249,9 +250,13 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
const nextLevel = (invoice.reminder_level || 0) + 1; const nextLevel = (invoice.reminder_level || 0) + 1;
const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2; const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2;
const baseUrl = process.env.FRONTEND_URL // FRONTEND_URL -> general_site_url (the setup wizard's answer) -> the
// legacy app_frontend_url key -> localhost. Was defaulting to
// https://app.example.com, which shipped a dead placeholder domain into
// customer-facing payment-reminder emails (#705).
const baseUrl = (await getFrontendBaseUrl())
|| (await getAppSetting('app_frontend_url')) || (await getAppSetting('app_frontend_url'))
|| 'https://app.example.com'; || DEFAULT_ABSOLUTE_BASE;
const buildUrl = (action) => const buildUrl = (action) =>
`${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`; `${baseUrl.replace(/\/$/, '')}/payment-check/${token}?action=${action}`;
+4 -3
View File
@@ -40,8 +40,9 @@ const UPLOAD_TOKEN_LENGTH = 6;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
function getFrontendUrl() { async function getFrontendUrl() {
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, ''); const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
return getAbsoluteFrontendUrl();
} }
function generateDownloadToken() { function generateDownloadToken() {
@@ -871,7 +872,7 @@ async function sendTransferEmails(transferId, emails) {
const fileCount = (Number(fileCountRow?.c) || 0) + (Number(extraCountRow?.c) || 0); const fileCount = (Number(fileCountRow?.c) || 0) + (Number(extraCountRow?.c) || 0);
const { sendTemplateEmail } = require('./emailProcessor'); const { sendTemplateEmail } = require('./emailProcessor');
const downloadUrl = `${getFrontendUrl()}/transfer/${transfer.token}`; const downloadUrl = `${await getFrontendUrl()}/transfer/${transfer.token}`;
const vars = { const vars = {
transfer_title: transfer.title || `Transfer #${transferId}`, transfer_title: transfer.title || `Transfer #${transferId}`,
message: transfer.message || '', message: transfer.message || '',
@@ -7,6 +7,7 @@ const { db } = require('../database/db');
const { checkForUpdates } = require('./updateCheckService'); const { checkForUpdates } = require('./updateCheckService');
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor'); const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
/** /**
* Get update notification settings from database * Get update notification settings from database
@@ -128,7 +129,7 @@ async function checkAndNotifyUpdates() {
await initializeTransporter(); await initializeTransporter();
// Send email to each recipient // Send email to each recipient
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = await getAbsoluteFrontendUrl();
const releaseNotesUrl = `https://github.com/PicPeak/picpeak/releases/tag/v${newVersion}`; const releaseNotesUrl = `https://github.com/PicPeak/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable'; const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
@@ -9,6 +9,7 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { generateReadablePassword } = require('../utils/passwordGenerator'); const { generateReadablePassword } = require('../utils/passwordGenerator');
const { getBcryptRounds } = require('../utils/passwordValidation'); const { getBcryptRounds } = require('../utils/passwordValidation');
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
const { queueEmail } = require('./emailProcessor'); const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { ConflictError, NotFoundError, ValidationError, ForbiddenError } = require('../utils/errors'); const { ConflictError, NotFoundError, ValidationError, ForbiddenError } = require('../utils/errors');
@@ -63,7 +64,14 @@ async function createInvitation({ email, roleId, invitedById, inviterRoleName })
const id = invitationId?.id || invitationId; const id = invitationId?.id || invitationId;
// Queue invitation email // Queue invitation email
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005'; // Keeps the original FRONTEND_URL-before-ADMIN_URL order: ADMIN_URL is the
// resolver's override, so a split-origin install that points it at a
// separate admin host still wins over the general_site_url setting - it
// must not sit AFTER the resolver, which only returns falsy when nothing at
// all is configured (#1104). Only the terminal fallback changes, from
// localhost:3005 - not even the frontend's port, so an unconfigured install
// mailed admin invites pointing nowhere - to the resolved origin (#705).
const frontendUrl = await getAbsoluteFrontendUrl(null, { override: process.env.ADMIN_URL });
await queueEmail(null, email, 'admin_invitation', { await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/invite/${token}`, invite_link: `${frontendUrl}/invite/${token}`,
role_name: role.display_name, role_name: role.display_name,
+175 -12
View File
@@ -1,27 +1,190 @@
const { db } = require('../database/db'); const { db } = require('../database/db');
const getFrontendBaseUrl = async () => { // Terminal fallback for callers that need an ABSOLUTE url (emails, QR codes,
let base = (process.env.FRONTEND_URL || '').trim().replace(/\/$/, ''); // payment links). Deliberately not the fallback of getFrontendBaseUrl(): some
if (base) return base; // callers (shareLinkService, the SSO redirects in routes/auth) rely on an
// empty base to emit a RELATIVE url, which is the better answer for a
// same-origin redirect.
const DEFAULT_ABSOLUTE_BASE = 'http://localhost:3000';
// A loopback base is treated as "not configured" so a better answer can win.
// Rationale (#705): docker-compose used to inject
// FRONTEND_URL=http://localhost:3000 unconditionally, so millions of installs
// have it baked into their environment; taking it literally means gallery
// links, QR codes and reminder emails point every recipient at THEIR OWN
// machine. The same guard already existed locally in routes/gallery.js for
// the slideshow QR (#848) and is centralised here.
//
// The host token has to end at a real boundary. Bare prefix matching (which is
// what routes/gallery.js did while this only gated the QR) also demotes
// https://localhost-nas.example.com — a legitimate public host, and now that
// this predicate decides "is this configured" for the entire resolver, being
// demoted means the operator's configured address is silently ignored.
// 127. stays a bare prefix on purpose: all of 127.0.0.0/8 is loopback.
const LOOPBACK_BASE_RE = /^https?:\/\/(localhost(?=[:/?#]|$)|127\.|0\.0\.0\.0(?=[:/?#]|$)|\[::1\])/i;
const isLoopbackBase = (url) => !!url && LOOPBACK_BASE_RE.test(url);
const normalise = (val) => (typeof val === 'string' ? val.trim().replace(/\/+$/, '') : '');
// Cached copy of the `general_site_url` setting. Two reasons: this now sits in
// per-request paths (CORS headers), and the sync accessor below has no other
// way to see the database. Invalidated explicitly when settings are written;
// the TTL is the backstop for writes that bypass that path (a restore, a
// direct SQL edit), so the documented worst case is CACHE_MS of staleness.
const CACHE_MS = 30_000;
let cache = { value: '', at: 0, primed: false };
const readSettingFromDb = async () => {
try { try {
const setting = await db('app_settings') const setting = await db('app_settings')
.where('setting_key', 'general_site_url') .where('setting_key', 'general_site_url')
.select('setting_value') .select('setting_value')
.first(); .first();
if (setting && setting.setting_value) { if (!setting || !setting.setting_value) return '';
let val = setting.setting_value; let val = setting.setting_value;
if (typeof val === 'string') { if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (_) {} try { val = JSON.parse(val); } catch (_) { /* stored as a bare string */ }
} }
if (typeof val === 'string' && val.trim()) { return normalise(typeof val === 'string' ? val : '');
base = val.trim().replace(/\/$/, ''); } catch (_) {
return '';
} }
}
} catch (_) {}
return base;
}; };
module.exports = { getFrontendBaseUrl }; const getSiteUrlSetting = async () => {
const now = Date.now();
if (cache.primed && now - cache.at < CACHE_MS) return cache.value;
const value = await readSettingFromDb();
cache = { value, at: now, primed: true };
return value;
};
// Expire the entry WITHOUT blanking it, then refresh in the background.
// Blanking would leave the synchronous accessor (the CORS allowlists) with no
// value at all until some async caller happened to re-read, which for
// Access-Control-Allow-Origin means falling back to '*' - a wider header than
// the operator configured. A briefly-stale origin is the safer trade.
const invalidateSiteUrlCache = () => {
cache = { ...cache, at: 0 };
primeSiteUrlCache().catch(() => {});
};
// The origin the request itself arrived on. `trust proxy` is configured in
// server.js, so req.protocol honours X-Forwarded-Proto behind the standard
// reverse proxies. Note frontend/nginx.conf forwards $host with the port
// stripped, so this can lose a non-default port on the split stack — which is
// why the setup wizard persists the browser's own window.location.origin
// instead of relying on this. It stays a last resort, and it is unavailable
// entirely to background jobs (reminder emails) that have no request.
const originFromRequest = (req) => {
if (!req || typeof req.get !== 'function') return '';
const host = req.get('host');
if (!host) return '';
const proto = req.protocol || 'http';
return normalise(`${proto}://${host}`);
};
const envBase = () => normalise(process.env.FRONTEND_URL);
// The env value ONLY when it actually wins the resolution below — i.e. set and
// not loopback. A loopback FRONTEND_URL is demoted by getFrontendBaseUrl(), so
// reporting it as authoritative would lock the admin UI's Site URL field for
// exactly the operators this exists to unblock: an upgrade that still carries
// the old compose default FRONTEND_URL=http://localhost:3000 must be able to
// configure its public address through the wizard and Settings (#1104).
const envPinnedBase = () => {
const env = envBase();
return env && !isLoopbackBase(env) ? env : '';
};
// Is the public origin pinned by the environment? The admin UI uses this to
// show the Site URL field as read-only, so an operator never edits a setting
// that an env var is silently overriding. Mirrors the resolver exactly.
const isEnvPinned = () => !!envPinnedBase();
/**
* Resolve the public origin, best answer first:
* 1. FRONTEND_URL, when it is not loopback
* 2. `options.override` - a purpose-specific env var (ADMIN_URL, APP_URL)
* the caller passes in, when it is not loopback
* 3. the `general_site_url` setting, when it is not loopback
* 4. the origin this request arrived on, when it is not loopback
* 5. whichever of the above exists at all (covers a genuine localhost install)
* 6. '' - caller decides between a relative url and DEFAULT_ABSOLUTE_BASE
*
* (2) exists because split-origin deployments are supported (API_URL, #798):
* an operator who sets ADMIN_URL for a separate admin host has stated an
* explicit per-purpose intent, and that must beat a value derived from the
* database or from whichever request happened to trigger the email (#1104).
* It sits BELOW FRONTEND_URL to preserve the historic
* `FRONTEND_URL || ADMIN_URL` order those call sites used.
*/
const getFrontendBaseUrl = async (req, options = {}) => {
// Short-circuit before touching the database: a pinned, non-loopback
// FRONTEND_URL is the answer, and this runs in per-request paths.
const env = envBase();
if (env && !isLoopbackBase(env)) return env;
const override = normalise(options.override);
if (override && !isLoopbackBase(override)) return override;
const setting = await getSiteUrlSetting();
if (setting && !isLoopbackBase(setting)) return setting;
const request = originFromRequest(req);
if (request && !isLoopbackBase(request)) return request;
return env || override || setting || request || '';
};
/** Same precedence, for callers that must produce an absolute url. */
const getAbsoluteFrontendUrl = async (req, options = {}) =>
(await getFrontendBaseUrl(req, options)) || DEFAULT_ABSOLUTE_BASE;
/**
* Synchronous variant for call sites that cannot await (Express middleware
* setting a response header). Sees the environment and the last cached
* setting only - it never reaches the database - so prime the cache at boot
* via primeSiteUrlCache().
*/
const getFrontendBaseUrlSync = (req) => {
const env = envBase();
const setting = cache.primed ? cache.value : '';
const request = originFromRequest(req);
for (const candidate of [env, setting, request]) {
if (candidate && !isLoopbackBase(candidate)) return candidate;
}
return env || setting || request || '';
};
const primeSiteUrlCache = async () => {
cache = { value: await readSettingFromDb(), at: Date.now(), primed: true };
return cache.value;
};
/**
* Public API origin used for assets that email clients must load. Explicit
* API_URL wins (split-origin deployments); otherwise it is the resolved
* public origin + /api, so the wizard's single answer covers it.
*/
const getApiBaseUrl = async (req) => {
const explicit = normalise(process.env.API_URL);
if (explicit) return explicit;
return `${await getAbsoluteFrontendUrl(req)}/api`;
};
module.exports = {
getFrontendBaseUrl,
getAbsoluteFrontendUrl,
getFrontendBaseUrlSync,
getApiBaseUrl,
invalidateSiteUrlCache,
primeSiteUrlCache,
isLoopbackBase,
isEnvPinned,
envPinnedBase,
DEFAULT_ABSOLUTE_BASE,
};
+5 -2
View File
@@ -59,7 +59,10 @@ services:
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS} - SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM:[email protected]} - EMAIL_FROM=${EMAIL_FROM:[email protected]}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000} # Unset by default (#705): an injected value would always win over the
# `general_site_url` admin setting, so the setup wizard could never
# take effect. Set this only to pin the origin from config-as-code.
- FRONTEND_URL=${FRONTEND_URL:-}
# Public API origin for split-origin deployments (#798 SSO redirect_uri). # Public API origin for split-origin deployments (#798 SSO redirect_uri).
# Empty = same origin as FRONTEND_URL (the standard proxied setup). # Empty = same origin as FRONTEND_URL (the standard proxied setup).
- API_URL=${API_URL:-} - API_URL=${API_URL:-}
@@ -68,7 +71,7 @@ services:
# password login when the IdP is down while SSO-only mode is active. # password login when the IdP is down while SSO-only mode is active.
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-} - OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-} - OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001} - ADMIN_URL=${ADMIN_URL:-}
- TZ=${TZ:-UTC} - TZ=${TZ:-UTC}
- STORAGE_PATH=/app/storage - STORAGE_PATH=/app/storage
# Watch-folder auto-import: max photos processed in parallel (default 2). # Watch-folder auto-import: max photos processed in parallel (default 2).
+5 -4
View File
@@ -79,8 +79,8 @@ Only `JWT_SECRET` is required. Everything else has a working default.
|---|---|---| |---|---|---|
| `JWT_SECRET` | — | **Required.** Long random string. | | `JWT_SECRET` | — | **Required.** Long random string. |
| `PORT` | `3000` | Listen port inside the container. | | `PORT` | `3000` | Listen port inside the container. |
| `FRONTEND_URL` | — | Public URL. Set it once you are behind a domain, so emails and share links point at the right host. | | `FRONTEND_URL` | — | Optional override for the public URL. Normally you set this in the setup wizard instead (it proposes the address you opened), and it is editable later under Settings → General. Setting it here pins the value and makes that field read-only. |
| `SMTP_*` | — | Outbound email. Without it, PicPeak runs fine but sends nothing. | | `SMTP_*` | — | Optional override for outbound email, which is normally configured in the setup wizard / Settings → Email. Without either, PicPeak runs fine but sends nothing. |
| `DATABASE_CLIENT` | `sqlite3` | Set to `pg` to use an external PostgreSQL. Required — the image declares `sqlite3`, and the boot resolver treats a declared client as an explicit instruction, so `DB_*` alone will **not** switch engines. | | `DATABASE_CLIENT` | `sqlite3` | Set to `pg` to use an external PostgreSQL. Required — the image declares `sqlite3`, and the boot resolver treats a declared client as an explicit instruction, so `DB_*` alone will **not** switch engines. |
| `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Connection details, used when `DATABASE_CLIENT=pg`. | | `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | — | Connection details, used when `DATABASE_CLIENT=pg`. |
@@ -100,8 +100,9 @@ migrations, exactly as the compose backend does.
## TLS ## TLS
None is included. Terminate TLS in front of it — your NAS's reverse proxy, None is included. Terminate TLS in front of it — your NAS's reverse proxy,
Caddy, nginx, or a Cloudflare Tunnel. Set `FRONTEND_URL` to the public Caddy, nginx, or a Cloudflare Tunnel. Put the public `https://…` address in
`https://…` address so generated links match. Settings → General (or re-run the setup wizard) so generated links match
`FRONTEND_URL` does the same thing but pins it outside the admin UI.
## NAS notes ## NAS notes
+130 -17
View File
@@ -7,9 +7,13 @@ import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service'; import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service'; import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service'; import { emailService, type EmailConfig } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { isAbsoluteHttpUrl } from '../../utils/url';
// Features that need working SMTP to deliver anything. // Email is NOT feature-gated (#705): a gallery-only install still mails the
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills']; // gallery link, guest invites and expiry warnings through the same
// email_configs row, so hiding SMTP behind the CRM-ish features left the most
// basic install unable to deliver anything.
interface Props { interface Props {
selectedFeatures: Set<FeatureKey>; selectedFeatures: Set<FeatureKey>;
@@ -18,12 +22,13 @@ interface Props {
// Lean per-feature config, shown after the "How will you use PicPeak?" step. // Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything // Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is // else keeps its seeded defaults and is tunable later in Settings. Every field
// best-effort per section — a failure never traps the user on setup. // is optional"Skip for now" always leaves — but a section the user DID fill
// in is validated before it is posted, and a save that fails keeps them on the
// step with their input intact rather than advancing into a silent data loss.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => { export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills'); const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({ const [inv, setInv] = useState({
@@ -33,14 +38,97 @@ export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) =
const [mail, setMail] = useState({ const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '', smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
}); });
// Prefilled with the address the admin actually reached the wizard on, which
// on a NAS or LAN install is the one thing no default can guess (#705).
const [siteUrl, setSiteUrl] = useState(window.location.origin.replace(/\/+$/, ''));
const [errors, setErrors] = useState<Record<string, string>>({});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) => const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value })); setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) => const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value })); setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => { // Persist the public origin on BOTH paths — skipping the optional invoicing
// and SMTP sections must not also skip the address that every gallery link,
// QR code and reminder email is built from.
//
// Throws rather than swallowing: PUT /general applies its own URL check, and
// a rejection here means the wizard would otherwise finish reporting success
// with no public address stored at all — the silent misconfiguration this
// whole change exists to remove. Callers decide what to do with it.
const saveSiteUrl = async () => {
const value = siteUrl.trim().replace(/\/+$/, '');
if (!value) return;
await settingsService.updateSettings({ general_site_url: value });
};
const siteUrlRejected = () => t(
'setup.config.siteUrlRejected',
'The server rejected this address. Use the full origin, for example https://gallery.example.com or http://192.168.1.50:3000.',
);
// Blocking validation, run before anything is posted. Everything on this
// step is optional, but a value that IS filled in has to be usable: the
// public address feeds the CORS allowlist (#705), and /admin/email/config
// rejects a config whose from_email isn't a valid address — which used to
// surface as a generic warning while the wizard advanced anyway, throwing
// away every SMTP value including the password (#1104).
const validate = () => {
const next: Record<string, string> = {};
if (siteUrl.trim() && !isAbsoluteHttpUrl(siteUrl)) {
next.siteUrl = t('setup.config.siteUrlInvalid', 'Enter the full address including http:// or https://, for example https://gallery.example.com');
}
if (mail.smtp_host.trim()) {
const from = mail.from_email.trim();
if (!from) {
next.from_email = t('setup.config.fromEmailRequired', 'A From address is required when an SMTP host is set.');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(from)) {
next.from_email = t('setup.config.fromEmailInvalid', 'Enter a valid email address.');
}
const port = parseInt(mail.smtp_port, 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
next.smtp_port = t('setup.config.smtpPortInvalid', 'Enter a port between 1 and 65535.');
}
}
setErrors(next);
return Object.keys(next).length === 0;
};
const skip = async () => {
if (siteUrl.trim() && !isAbsoluteHttpUrl(siteUrl)) {
setErrors({ siteUrl: t('setup.config.siteUrlInvalid', 'Enter the full address including http:// or https://, for example https://gallery.example.com') });
return;
}
setErrors({});
setSaving(true); setSaving(true);
// "Skip for now" always leaves, by contract — but say so rather than
// dropping the address without a word.
try {
await saveSiteUrl();
} catch {
toast.warn(siteUrlRejected());
}
setSaving(false);
onDone();
};
const finish = async () => {
if (!validate()) return;
setSaving(true);
let failed = false;
// Separate from the block below so the message lands on the address field
// instead of the generic "some settings could not be saved" warning. The
// server check is stricter than isAbsoluteHttpUrl in places, so this is
// reachable even after validate() has passed.
try {
await saveSiteUrl();
} catch {
setSaving(false);
setErrors((prev) => ({ ...prev, siteUrl: siteUrlRejected() }));
return;
}
try { try {
// Invoicing: only persist if they actually started filling it in. // Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) { if (showInvoicing && inv.companyName.trim()) {
@@ -64,7 +152,7 @@ export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) =
} }
} }
// Email: only persist if a host was entered. // Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) { if (mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587; const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = { const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(), smtp_host: mail.smtp_host.trim(),
@@ -78,20 +166,39 @@ export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) =
}; };
await emailService.updateConfig(config); await emailService.updateConfig(config);
} }
} catch (_) { } catch {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.')); // Stay on the step: advancing here discarded everything the user typed,
// the SMTP password included, with no way back to re-enter it (#1104).
failed = true;
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — check the values below, or use “Skip for now” and finish in Settings.'));
} finally { } finally {
setSaving(false); setSaving(false);
onDone();
} }
if (!failed) onDone();
}; };
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600"> <p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')} {t('setup.config.intro', 'A few details to finish setting up. Anything you skip keeps its default and can be set later in Settings.')}
</p> </p>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">
{t('setup.config.siteUrl', 'Public address')}
</h3>
<p className="text-xs text-neutral-500">
{t('setup.config.siteUrlHint', 'Where your clients will reach this gallery. Prefilled with the address you opened right now — change it if you will put PicPeak behind a domain or reverse proxy. You can update this any time in Settings → General.')}
</p>
<Input
type="url"
placeholder="https://gallery.example.com"
value={siteUrl}
onChange={(e) => setSiteUrl(e.target.value)}
error={errors.siteUrl}
/>
</div>
{showInvoicing && ( {showInvoicing && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3> <h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
@@ -119,27 +226,33 @@ export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) =
</div> </div>
)} )}
{showEmail && (
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3> <h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p> <p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Used to send gallery links to your clients, plus guest invites, expiry warnings and any reminders or invoices you enable. Leave blank to set it up later in Settings → Email.')}</p>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div> <div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} /> <Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} error={errors.smtp_port} />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" /> <Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" /> <Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} /> <Input
type="email"
placeholder={mail.smtp_host.trim()
? t('setup.config.fromEmailRequiredPlaceholder', 'From address (required)')
: t('setup.config.fromEmail', 'From address')}
value={mail.from_email}
onChange={mailField('from_email')}
error={errors.from_email}
/>
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} /> <Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div> </div>
</div> </div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}> <Button type="button" variant="outline" size="lg" onClick={skip} disabled={saving}>
{t('setup.config.skip', 'Skip for now')} {t('setup.config.skip', 'Skip for now')}
</Button> </Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}> <Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
@@ -0,0 +1,107 @@
/**
* Site URL validation must not strand an admin on load (#1104).
*
* `general_site_url` was free-text until this change added a server-side
* check, so an upgraded install can hold a schemeless value nobody typed
* today. Flagging that on load disables Save for every General setting and
* an admin holding `settings.edit` but not `settings.domains` cannot clear it
* either, because correcting the address is a change to a protected key and
* 403s. The tab has no permission gating, so they would simply be locked out.
*/
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { GeneralTab } from '../tabs/GeneralTab';
import type { GeneralSettings } from '../hooks/useSettingsState';
vi.mock('../components/MfaSettingsCard', () => ({ MfaSettingsCard: () => null }));
const base: GeneralSettings = {
site_url: '',
site_url_env_pinned: false,
site_url_stored: '',
default_expiration_days: 30,
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,png',
max_upload_batch_size_mb: 95,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
short_gallery_urls: false,
use_original_filenames_for_downloads: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' },
time_format: '24h',
};
function renderTab(overrides: Partial<GeneralSettings>) {
let settings = { ...base, ...overrides };
const setGeneralSettings = vi.fn((updater) => {
settings = typeof updater === 'function' ? updater(settings) : updater;
rerender(<Tab />);
});
const Tab = () => (
<GeneralTab
generalSettings={settings}
setGeneralSettings={setGeneralSettings as never}
saveGeneralMutation={{ mutate: vi.fn(), isPending: false }}
accountForm={{ username: 'a', email: '[email protected]' }}
accountErrors={{}}
handleAccountChange={() => () => {}}
handleAccountSubmit={() => {}}
updateAdminProfileMutation={{ isPending: false }}
adminProfileLoading={false}
/>
);
const { rerender } = render(<Tab />);
return {
saveButton: () => screen.getByRole('button', { name: /save general settings|allgemeine/i }),
urlInput: () => screen.getByPlaceholderText('https://yourdomain.com'),
};
}
describe('GeneralTab — Site URL validation', () => {
it('does not block Save on a stored value the admin never touched', () => {
// The upgrade case: schemeless value already in the database.
const { saveButton } = renderTab({
site_url: 'gallery.example.com',
site_url_stored: 'gallery.example.com',
});
expect(saveButton()).not.toBeDisabled();
});
it('blocks Save once the admin edits it to something unusable', () => {
const { saveButton, urlInput } = renderTab({
site_url: 'https://gallery.example.com',
site_url_stored: 'https://gallery.example.com',
});
expect(saveButton()).not.toBeDisabled();
fireEvent.change(urlInput(), { target: { value: 'gallery.example.com' } });
expect(saveButton()).toBeDisabled();
});
it('allows Save when the edit is a usable absolute url', () => {
const { saveButton, urlInput } = renderTab({
site_url: '',
site_url_stored: '',
});
// Bare hosts and IPs are fine — LAN and NAS installs run on those.
fireEvent.change(urlInput(), { target: { value: 'http://nas:3000' } });
expect(saveButton()).not.toBeDisabled();
});
it('stays quiet while the environment pins the value', () => {
// Pinned seeds the field with the EFFECTIVE env value, which differs from
// the stored setting — that difference must not read as an edit.
const { saveButton, urlInput } = renderTab({
site_url: 'http://localhost:3000',
site_url_stored: 'gallery.example.com',
site_url_env_pinned: true,
});
expect(saveButton()).not.toBeDisabled();
expect(urlInput()).toBeDisabled();
});
});
@@ -12,6 +12,16 @@ export const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
export interface GeneralSettings { export interface GeneralSettings {
site_url: string; site_url: string;
// FRONTEND_URL in the environment overrides general_site_url at runtime
// (#705). Surfaced so the field can say so instead of accepting edits
// that never take effect.
site_url_env_pinned: boolean;
// The value as stored, so the tab can tell an edit from an untouched load.
// `general_site_url` was free-text before #1104 added validation, so an
// upgraded install can hold something schemeless — and blocking Save on a
// value the admin never touched strands anyone without settings.domains,
// who cannot correct it either (the write 403s on the protected key).
site_url_stored: string;
default_expiration_days: number; default_expiration_days: number;
max_file_size_mb: number; max_file_size_mb: number;
max_files_per_upload: number; max_files_per_upload: number;
@@ -114,6 +124,8 @@ export function useSettingsState() {
// General settings state // General settings state
const [generalSettings, setGeneralSettings] = useState<GeneralSettings>({ const [generalSettings, setGeneralSettings] = useState<GeneralSettings>({
site_url: '', site_url: '',
site_url_env_pinned: false,
site_url_stored: '',
default_expiration_days: 30, default_expiration_days: 30,
max_file_size_mb: 50, max_file_size_mb: 50,
max_files_per_upload: 500, max_files_per_upload: 500,
@@ -200,7 +212,11 @@ export function useSettingsState() {
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setGeneralSettings({ setGeneralSettings({
site_url: settings.general_site_url || '', site_url: settings.general_site_url_env_pinned
? (settings.general_site_url_effective || '')
: (settings.general_site_url || ''),
site_url_env_pinned: Boolean(settings.general_site_url_env_pinned),
site_url_stored: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30), default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50), max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
max_files_per_upload: Math.min( max_files_per_upload: Math.min(
@@ -320,6 +336,22 @@ export function useSettingsState() {
mutationFn: async () => { mutationFn: async () => {
const settingsData: Record<string, unknown> = {}; const settingsData: Record<string, unknown> = {};
Object.entries(generalSettings).forEach(([key, value]) => { Object.entries(generalSettings).forEach(([key, value]) => {
// `site_url_env_pinned` is derived server-side and stripped there, and
// while it IS pinned the field shows the *effective* env value rather
// than the stored setting — reposting that would look like a genuine
// change to a protected key and 403 an admin who holds settings.edit
// but not settings.domains, even though they changed nothing. The
// backend's no-op round-trip allowance only covers the stored value,
// so don't send the key at all while it's read-only (#1104).
if (key === 'site_url_env_pinned' || key === 'site_url_stored') return;
if (key === 'site_url' && generalSettings.site_url_env_pinned) return;
// Unchanged is a no-op, so don't send it. The backend allows a no-op
// round-trip of a protected key precisely so a settings.edit admin
// without settings.domains can save unrelated General settings — but
// that only helps if the request gets made, and an untouched value
// that predates the #1104 validation would otherwise be blocked in
// the tab before it ever left the browser.
if (key === 'site_url' && value === generalSettings.site_url_stored) return;
settingsData[`general_${key}`] = value; settingsData[`general_${key}`] = value;
}); });
return settingsService.updateSettings(settingsData); return settingsService.updateSettings(settingsData);
@@ -6,6 +6,7 @@ import type { GeneralSettings } from '../hooks/useSettingsState';
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState'; import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx"; import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
import { MfaSettingsCard } from '../components/MfaSettingsCard'; import { MfaSettingsCard } from '../components/MfaSettingsCard';
import { isAbsoluteHttpUrl } from '../../../utils/url';
interface GeneralTabProps { interface GeneralTabProps {
generalSettings: GeneralSettings; generalSettings: GeneralSettings;
@@ -35,6 +36,26 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// The public address reaches the CORS allowlist and the
// Access-Control-Allow-Origin header since #705, not just email links — and
// the `type="url"` constraint never fires because this input isn't inside a
// <form>. Validate it here so a schemeless value can't be saved, mirroring
// the server-side check in adminSettings.js (#1104).
//
// Only once the admin has actually touched the field. The key was free-text
// until #1104, so an upgraded install can hold a schemeless value nobody
// typed today — and flagging that on load would disable Save for every
// General setting. An admin with `settings.edit` but not `settings.domains`
// could not clear it either: correcting the address is a change to a
// protected key and 403s. They would simply be locked out of the tab.
const siteUrlDirty = generalSettings.site_url !== generalSettings.site_url_stored;
const siteUrlError = !generalSettings.site_url_env_pinned
&& siteUrlDirty
&& generalSettings.site_url.trim()
&& !isAbsoluteHttpUrl(generalSettings.site_url)
? t('settings.general.siteUrlInvalid', 'Enter the full address including http:// or https://, for example https://gallery.example.com')
: undefined;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card padding="md"> <Card padding="md">
@@ -113,10 +134,16 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))} onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))}
placeholder="https://yourdomain.com" placeholder="https://yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />} leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
disabled={generalSettings.site_url_env_pinned}
error={siteUrlError}
/> />
{!siteUrlError && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.siteUrlHelp')} {generalSettings.site_url_env_pinned
? t('settings.general.siteUrlEnvPinned', 'Pinned by the FRONTEND_URL environment variable, which overrides this setting. Remove it from your .env (or container environment) and restart to manage the address here.')
: t('settings.general.siteUrlHelp')}
</p> </p>
)}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -355,6 +382,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
variant="primary" variant="primary"
onClick={() => saveGeneralMutation.mutate()} onClick={() => saveGeneralMutation.mutate()}
isLoading={saveGeneralMutation.isPending} isLoading={saveGeneralMutation.isPending}
disabled={!!siteUrlError}
leftIcon={<Save className="w-5 h-5" />} leftIcon={<Save className="w-5 h-5" />}
> >
{t('settings.general.saveGeneralSettings')} {t('settings.general.saveGeneralSettings')}
+14 -4
View File
@@ -1509,6 +1509,7 @@
"siteConfiguration": "Website-Konfiguration", "siteConfiguration": "Website-Konfiguration",
"siteUrl": "Website-URL", "siteUrl": "Website-URL",
"siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet", "siteUrlHelp": "Wird für die Generierung von Galerielinks in E-Mails verwendet",
"siteUrlEnvPinned": "Durch die Umgebungsvariable FRONTEND_URL festgelegt; sie überschreibt diese Einstellung. Entfernen Sie sie aus Ihrer .env (oder der Container-Umgebung) und starten Sie neu, um die Adresse hier zu verwalten.",
"defaultExpiration": "Standardablauf (Tage)", "defaultExpiration": "Standardablauf (Tage)",
"maxFileSize": "Max. Dateigröße (MB)", "maxFileSize": "Max. Dateigröße (MB)",
"maxFilesPerUpload": "Max. Dateien pro Upload", "maxFilesPerUpload": "Max. Dateien pro Upload",
@@ -1541,7 +1542,8 @@
"accountEmailRequired": "E-Mail-Adresse ist erforderlich", "accountEmailRequired": "E-Mail-Adresse ist erforderlich",
"accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben", "accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben",
"accountSaveButton": "Kontodaten speichern", "accountSaveButton": "Kontodaten speichern",
"accountSaveSuccess": "Kontodaten aktualisiert" "accountSaveSuccess": "Kontodaten aktualisiert",
"siteUrlInvalid": "Geben Sie die vollständige Adresse inklusive http:// oder https:// an, zum Beispiel https://galerie.example.com"
}, },
"publicSite": { "publicSite": {
"badge": "Öffentliche Landingpage", "badge": "Öffentliche Landingpage",
@@ -3919,7 +3921,7 @@
"restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.", "restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.",
"config": { "config": {
"subtitle": "Richten Sie Ihre Funktionen ein", "subtitle": "Richten Sie Ihre Funktionen ein",
"intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.", "intro": "Ein paar Angaben zum Abschluss der Einrichtung. Alles, was Sie überspringen, behält seinen Standardwert und kann später in den Einstellungen gesetzt werden.",
"invoicing": "Rechnungsdaten", "invoicing": "Rechnungsdaten",
"invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.", "invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.",
"companyName": "Firma / rechtlicher Name", "companyName": "Firma / rechtlicher Name",
@@ -3932,7 +3934,7 @@
"taxId": "Steuernummer (oder MwSt-Nummer)", "taxId": "Steuernummer (oder MwSt-Nummer)",
"iban": "IBAN (für Rechnungszahlungen)", "iban": "IBAN (für Rechnungszahlungen)",
"email": "E-Mail-Versand (SMTP)", "email": "E-Mail-Versand (SMTP)",
"emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.", "emailHint": "Wird verwendet, um Galerielinks an Ihre Kundinnen und Kunden zu senden, ausserdem für Gasteinladungen, Ablaufwarnungen sowie Erinnerungen und Rechnungen, die Sie aktivieren. Leer lassen, um dies später unter Einstellungen → E-Mail einzurichten.",
"smtpHost": "SMTP-Host", "smtpHost": "SMTP-Host",
"smtpPort": "Port", "smtpPort": "Port",
"smtpUser": "Benutzername", "smtpUser": "Benutzername",
@@ -3941,7 +3943,15 @@
"fromName": "Absendername", "fromName": "Absendername",
"skip": "Vorerst überspringen", "skip": "Vorerst überspringen",
"finish": "Einrichtung abschließen", "finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen." "saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — prüfen Sie die Angaben unten oder wählen Sie „Vorerst überspringen“ und schliessen Sie sie in den Einstellungen ab.",
"siteUrl": "Öffentliche Adresse",
"siteUrlHint": "Unter dieser Adresse erreichen Ihre Kundinnen und Kunden die Galerie. Vorausgefüllt mit der Adresse, die Sie gerade geöffnet haben — ändern Sie sie, wenn Sie PicPeak hinter einer Domain oder einem Reverse Proxy betreiben. Sie können das jederzeit unter Einstellungen → Allgemein anpassen.",
"siteUrlInvalid": "Geben Sie die vollständige Adresse inklusive http:// oder https:// an, zum Beispiel https://galerie.example.com",
"siteUrlRejected": "Der Server hat diese Adresse abgelehnt. Verwenden Sie den vollständigen Ursprung, zum Beispiel https://galerie.example.com oder http://192.168.1.50:3000.",
"fromEmailRequiredPlaceholder": "Absenderadresse (erforderlich)",
"fromEmailRequired": "Eine Absenderadresse ist erforderlich, wenn ein SMTP-Host gesetzt ist.",
"fromEmailInvalid": "Geben Sie eine gültige E-Mail-Adresse ein.",
"smtpPortInvalid": "Geben Sie einen Port zwischen 1 und 65535 an."
}, },
"eventTypes": { "eventTypes": {
"subtitle": "Welche Veranstaltungen fotografieren Sie?", "subtitle": "Welche Veranstaltungen fotografieren Sie?",
+14 -4
View File
@@ -1050,6 +1050,7 @@
"siteConfiguration": "Site Configuration", "siteConfiguration": "Site Configuration",
"siteUrl": "Site URL", "siteUrl": "Site URL",
"siteUrlHelp": "Used for generating gallery links in emails", "siteUrlHelp": "Used for generating gallery links in emails",
"siteUrlEnvPinned": "Pinned by the FRONTEND_URL environment variable, which overrides this setting. Remove it from your .env (or container environment) and restart to manage the address here.",
"defaultExpiration": "Default Expiration (days)", "defaultExpiration": "Default Expiration (days)",
"maxFileSize": "Max File Size (MB)", "maxFileSize": "Max File Size (MB)",
"maxFilesPerUpload": "Max Files per Upload", "maxFilesPerUpload": "Max Files per Upload",
@@ -1082,7 +1083,8 @@
"accountEmailRequired": "Email address is required", "accountEmailRequired": "Email address is required",
"accountEmailInvalid": "Enter a valid email address", "accountEmailInvalid": "Enter a valid email address",
"accountSaveButton": "Save account details", "accountSaveButton": "Save account details",
"accountSaveSuccess": "Account details updated" "accountSaveSuccess": "Account details updated",
"siteUrlInvalid": "Enter the full address including http:// or https://, for example https://gallery.example.com"
}, },
"publicSite": { "publicSite": {
"badge": "Public Landing", "badge": "Public Landing",
@@ -3805,7 +3807,7 @@
"restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.", "restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.",
"config": { "config": {
"subtitle": "Set up your features", "subtitle": "Set up your features",
"intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.", "intro": "A few details to finish setting up. Anything you skip keeps its default and can be set later in Settings.",
"invoicing": "Invoicing details", "invoicing": "Invoicing details",
"invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.", "invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.",
"companyName": "Company / legal name", "companyName": "Company / legal name",
@@ -3818,7 +3820,7 @@
"taxId": "Tax number (or VAT ID)", "taxId": "Tax number (or VAT ID)",
"iban": "IBAN (for invoice payments)", "iban": "IBAN (for invoice payments)",
"email": "Email delivery (SMTP)", "email": "Email delivery (SMTP)",
"emailHint": "Required to send reminders, invoices and notifications.", "emailHint": "Used to send gallery links to your clients, plus guest invites, expiry warnings and any reminders or invoices you enable. Leave blank to set it up later in Settings → Email.",
"smtpHost": "SMTP host", "smtpHost": "SMTP host",
"smtpPort": "Port", "smtpPort": "Port",
"smtpUser": "Username", "smtpUser": "Username",
@@ -3827,7 +3829,15 @@
"fromName": "From name", "fromName": "From name",
"skip": "Skip for now", "skip": "Skip for now",
"finish": "Finish setup", "finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings." "saveFailed": "Some settings could not be saved — check the values below, or use “Skip for now” and finish in Settings.",
"siteUrl": "Public address",
"siteUrlHint": "Where your clients will reach this gallery. Prefilled with the address you opened right now — change it if you will put PicPeak behind a domain or reverse proxy. You can update this any time in Settings → General.",
"siteUrlInvalid": "Enter the full address including http:// or https://, for example https://gallery.example.com",
"siteUrlRejected": "The server rejected this address. Use the full origin, for example https://gallery.example.com or http://192.168.1.50:3000.",
"fromEmailRequiredPlaceholder": "From address (required)",
"fromEmailRequired": "A From address is required when an SMTP host is set.",
"fromEmailInvalid": "Enter a valid email address.",
"smtpPortInvalid": "Enter a port between 1 and 65535."
}, },
"eventTypes": { "eventTypes": {
"subtitle": "Which events do you photograph?", "subtitle": "Which events do you photograph?",
+22 -9
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../components/common'; import { Button, Input, Card, Loading } from '../components/common';
import { useAdminAuth } from '../contexts'; import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service'; import { setupService } from '../services/setup.service';
import { settingsService } from '../services/settings.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { SetupConfigStep } from '../components/admin/SetupConfigStep';
@@ -176,6 +177,20 @@ export const SetupPage: React.FC = () => {
role: { name: user.role.name, displayName: user.role.displayName ?? user.role.name }, role: { name: user.role.name, displayName: user.role.displayName ?? user.role.name },
}; };
login('', adminUser); login('', adminUser);
// Persist the origin the admin is standing on as the public site URL
// (#705). Doing it HERE, not in the optional config step, means an
// install that skips the rest of the wizard still has a usable origin
// for background jobs (reminder emails, QR codes) that have no request
// to derive one from. Best-effort: never block entering the app, and
// never overwrite a value the environment already pins.
try {
const existing = await settingsService.getSettingsByType('general');
if (!existing?.general_site_url_env_pinned && !existing?.general_site_url) {
await settingsService.updateSettings({
general_site_url: window.location.origin.replace(/\/+$/, ''),
});
}
} catch (_) { /* the config step offers the field again */ }
toast.success(t('setup.success')); toast.success(t('setup.success'));
// Admin now exists and we're logged in (cookie set) — advance to the // Admin now exists and we're logged in (cookie set) — advance to the
// opt-in "How will you use PicPeak?" step rather than jumping straight to // opt-in "How will you use PicPeak?" step rather than jumping straight to
@@ -249,16 +264,14 @@ export const SetupPage: React.FC = () => {
} }
}; };
// After the event-types step: if the chosen features need config the wizard // Always visit the config step (#705). It used to be skipped unless a
// can collect (invoicing, email), go to the config step; otherwise skip to // feature the wizard can configure (invoicing, email) was selected, which
// the final community/thank-you step (#732), whose Finish enters the app. // meant a gallery-only install never saw the two things EVERY install needs:
// the public address its client links are built from, and the SMTP settings
// that send them. Both sections are feature-independent; the invoicing block
// is still conditional inside the step.
const continueAfterEventTypes = () => { const continueAfterEventTypes = () => {
const needsConfig = setStep('config');
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
setStep(needsConfig ? 'config' : 'community');
}; };
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
+22
View File
@@ -159,3 +159,25 @@ export const buildShareLinkUrl = (link: string | null | undefined): string => {
const path = link.startsWith('/') ? link : `/gallery/${link}`; const path = link.startsWith('/') ? link : `/gallery/${link}`;
return buildFromOrigin(path); return buildFromOrigin(path);
}; };
/**
* Is this an absolute http(s) origin the browser could actually navigate to?
*
* Used for the public address (`general_site_url`), which since #705 feeds the
* CORS allowlist and the Access-Control-Allow-Origin header as well as every
* email link a schemeless "gallery.example.com" saves happily through a
* `type="url"` input that is not inside a <form>, and then matches no browser
* origin at all. Mirrors the backend check in adminSettings.js: a bare host or
* IP is fine (LAN and NAS installs run on http://nas:3000), the scheme is not.
*/
export const isAbsoluteHttpUrl = (value: string): boolean => {
if (!ABSOLUTE_URL_REGEX.test(value.trim())) {
return false;
}
try {
const parsed = new URL(value.trim());
return !!parsed.hostname;
} catch {
return false;
}
};