Commit Graph

221 Commits

Author SHA1 Message Date
Marian df83b3e923 test(api/v1): cover category scoping clause + 400 response
Unit test for the v1 upload route's category lookup, requested in
the PR review. Mocks db (chainable, mirroring src/routes/__tests__/
adminAuth.test.js) plus apiTokenAuth/requireApiScope (pass-through)
and multer (stub req.file). Two cases:

1. The scoping clause: the andWhere callback applied to a knex
   builder spy produces .where({event_id: <event.id>}).orWhere(
   'is_global', true) — exactly the contract the reviewer asked
   for, exercising the OR-clause rather than just asserting the
   callback was passed.
2. Null lookup result yields 400 with "Unknown or out-of-scope
   category_id <N>".

No v1 jest scaffolding existed before, but the project-wide harness
(backend/jest.config.js + jest.setup.js) already covers the new
file via testMatch '**/__tests__/**/*.test.js'. Happy-path tests
deferred — would require stubbing fs/sharp/imageProcessor/share
linkService and several more db chains, which the reviewer was
willing to accept as a separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 92bb9e1a12 fix(api/v1): scope category lookup to event_owned or global
PR review pointed out the original lookup
  db('photo_categories').where({ id: parsedCategoryId }).first()
accepted any category id — including one that belongs to a different
event. photo_categories carries both event_id (per-event) and is_global
(see backend/migrations/legacy/004_add_categories_and_cms.js); the v1
upload route should require either match.

Not a privilege issue (apiTokenAuth.js inherits the admin's powers, no
per-event scoping), but it lets a misconfigured uploader silently file
photos under a category the target event doesn't own — and the 201 echo
includes a category_id that makes no semantic sense.

Tighten to:
  .where({ id: parsedCategoryId })
  .andWhere(function () {
    this.where({ event_id: event.id }).orWhere('is_global', true);
  })
…and update the 400 message to "Unknown or out-of-scope category_id N".

OpenAPI description already documents the intended scope.

Tests deferred to a follow-up; v1 has no jest harness today, see PR
discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 6901e2661e feat(api/v1): accept category_id on POST /events/:id/photos
The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.

Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
  fail fast on misconfigured envs instead of silently uncategorized
  uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
  collage/collages, matching adminPhotos.

Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Paul Nothaft ebc7da21be Merge pull request #503 from filpgame/fix/email-language-json-parse
fix(email): parse JSON-encoded language setting before using as locale
2026-05-17 09:28:25 +02:00
Paul Nothaft a747eb351d Merge pull request #502 from filpgame/fix/category-slug-diacritics
fix(categories): strip diacritics from auto-generated slugs
2026-05-17 09:26:39 +02:00
Paul Nothaft 061712ebf1 feat(i18n): add Spanish (es) locale (#510)
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.

Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
  EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
  those at runtime so the UI never renders a missing key. fr/nl/pt/ru
  have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
  horizontal bands, official #AA151B + #F1BF00; no coat of arms to
  stay consistent with the other simple flag components) and a new
  entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
  an `es:` row to the three inline-translated snippets
  (passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
  seeder for the four customer-facing templates AloePacci translated:
  gallery_created, expiration_warning, gallery_expired, archive_complete.
  Mirrors the pattern from 099. Template keys without an `es` row fall
  back to `en` via the existing resolution chain in
  emailProcessor.processTemplate — no functional gap, just untranslated
  copy until someone fills them in.

What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
2026-05-17 00:50:51 +02:00
Paul Nothaft 33de294d57 feat(lightbox): surface original camera filenames (#508)
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.

Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.

Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
  resolved toggle as `event.use_original_filenames` so the client can
  decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
  back to the storage filename only for pre-migration-062 uploads) in
  a muted line under the position counter, truncated to keep the
  toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
  rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
  added the Captions plugin and a `title` field on the slides so the
  same name appears as a caption when the toggle is on.

The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
2026-05-17 00:35:16 +02:00
Paul Nothaft 38343e62de fix(downloads): apply original-filename toggle to individual downloads too (#507)
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:

- Frontend overrode the server's Content-Disposition with a hardcoded
  `<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
  where X was the sanitized `photo.filename` known to the client. So
  the backend's correctly-formed `Content-Disposition` never reached
  the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
  plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
  route) was missed in #498 and still emitted a hardcoded
  `filename="${photo.filename}"` regardless of the toggle. Wired it
  through `getUseOriginalFilenames` + `buildContentDisposition` so it
  matches the regular gallery download path.

Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
2026-05-17 00:25:12 +02:00
filpgame f12062f1e7 fix(email): parse JSON-encoded language setting before using as locale
general_default_language is stored as a JSON string (e.g. "\"pt\"").
getRecipientLanguage() returned the raw value including quotes, causing
the translation lookup to miss every match and fall back to English.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:31:15 -03:00
filpgame 848430e72b fix(categories): strip diacritics from auto-generated slugs
Accented chars (ã, ç, é, etc.) were silently dropped by the slug
regex because \w only matches ASCII. NFD decomposition + combining
mark removal converts them to ASCII equivalents instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:23:03 -03:00
Paul Nothaft 826e43ebac Merge pull request #498 from the-luap/feat/lightbox-preview-tier-492
feat(downloads): preserve original camera filenames on download (opt-in) (#493)
2026-05-14 23:24:41 +02:00
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
2026-05-14 23:11:00 +02:00
Paul Nothaft 3083c748b9 Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492
feat(lightbox): medium-resolution preview tier (#492)
2026-05-14 22:44:35 +02:00
Paul Nothaft 61f1d13210 feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.

Backend:
  - imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
    using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
  - migration 104: photos.preview_path + lightbox_preview_enabled setting
    (off by default, JSON-stringified for SQLite/Postgres parity)
  - GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
    ETag based on mtime+photoId+watermarkHash
  - preview_url surfaced in the photo response only when the toggle is on
  - admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
    skipping videos
  - backup walk + archive cleanup + photo-delete now include previews/

Frontend:
  - PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
  - ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
    Regenerate All Previews button (gated until the toggle is on)
  - en/de locale strings; nl/pt/ru/fr fall back to en

Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
2026-05-14 22:30:39 +02:00
Paul Nothaft 87834a7fff fix(install): defer events.hero_photo_id FK to break circular reference (#484)
Real root cause behind MrGabri's fresh-Postgres install crash, surfaced
by his second log dump after #488 silenced the FATAL noise:

  Initial setup failed: error: alter table "events" add constraint
  "events_hero_photo_id_foreign" foreign key ("hero_photo_id")
  references "photos" ("id") on delete SET NULL
  - relation "photos" does not exist

initializeDatabase() in src/database/db.js declared the FK inline at
events createTable (line 89), but the photos table is created later
in the same function (line 203). On Postgres this is a hard error —
the referenced table must exist at FK-declaration time. SQLite
silently tolerated it because its FK enforcement is lazy and the
inline declaration just became a column with no FK metadata.

Why no existing Postgres install hit it: initializeDatabase only
runs the createTable block on `if (!hasEventsTable)`. Once a
deployment has the events table from any prior run, the path is
skipped. So the bug only ever fires on a truly fresh Postgres
install — which is exactly MrGabri's scenario, and which our smoke
suite never exercises (it runs against a long-lived dev stack).

Fix:

- events createTable: drop the inline FK; column declared as a plain
  integer with an explainer comment.
- After both tables exist (post photos createTable): db.schema
  .alterTable('events').foreign('hero_photo_id').references...
  Wrapped in a try/catch that swallows "already exists" so re-runs
  on installs that previously got into a half-state don't fail boot.

Verified by docker compose down -v + up against the dev stack — no
FK error, all migrations apply, FK present in pg_constraint with
the expected definition.
2026-05-14 21:21:23 +02:00
Paul Nothaft b6b58d0659 fix(admin-users): normalise date fields to ISO across DB drivers (#485)
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.

Fix at both layers — defence in depth:

- backend/src/routes/adminUsers.js: new toIso() helper applied in
  transformUser + transformInvitation. Coerces Date / number /
  numeric-string / null to a single ISO 8601 string contract before
  the response leaves the API. Protects every consumer (frontend
  AND external API tokens / n8n) regardless of which DB driver is
  underneath.
- frontend/src/services/userManagement.service.ts: same helper as
  defence-in-depth for stale backends mid-deploy and any cached
  pre-fix response shape. Also surfaced an existing
  transformInvitation gap — invitations endpoints were returning
  raw response.data.invitations without going through the
  transformer.

10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.

Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
2026-05-14 20:53:10 +02:00
Paul Nothaft a803491cf4 fix(promo-banner): center by default + admin alignment selector (#482)
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:

  - Footer used `container text-center px-4` (full container width,
    centered text).
  - Promo block used `container py-4 sm:py-6` with an inner
    `max-w-3xl mx-auto` wrapper holding left-aligned text — a
    narrower column with left-aligned content sitting in the
    middle of the page.

Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.

Fix:

- Drop the inner max-w-3xl wrapper. Promo content now spans the
  same .container width as the footer, eliminating the
  narrower-column visual.
- Default text alignment changed from left → center to match the
  footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
  default 'center'). Surfaced as a dropdown next to the existing
  Position dropdown on the BrandingPage. Live preview block on the
  BrandingPage mirrors the gallery render so admins see what
  guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
  `prose prose-sm` outer class so the existing `prose-a:text-accent`
  modifier actually takes effect (it didn't before — modifiers
  without an outer .prose are silently ignored by Tailwind
  Typography).

Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.

i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
2026-05-14 20:10:14 +02:00
Paul Nothaft 0bc7e2af17 feat(og): per-event opt-in to use hero photo as social-share preview (#474)
Background: galleryOgService already serves OG/Twitter Card meta tags
to social-crawler User-Agents (WhatsApp, Facebook, Slack, Telegram,
Discord, ~21 in total) for /gallery/:slug URLs. Today the og:image
is always the brand logo with the inline rationale "no protected
photo content".

#474 asked for a hero/cover photo preview. The trade-off is that any
URL embedded in og:image is fetched unauthenticated by every
link-preview crawler — so an opted-in image is effectively public
to anyone the gallery URL is shared to. Ship as a per-event boolean,
default FALSE, so existing galleries never start surfacing photos
without explicit admin intent.

Schema (migration 102):
  - events.og_image_share_enabled BOOLEAN NOT NULL DEFAULT FALSE.

Backend:
  - galleryOgService.buildOgMetadata: when opt-in is on AND a
    hero_photo_id is set AND the photo has a generated thumbnail,
    emit og:image as /og/gallery/:slug/cover. Falls back to the
    brand logo on any miss (deleted hero, missing thumbnail, no
    opt-in) so a half-configured gallery still gets a polished
    preview rather than a broken-image src.
  - galleryOgService.handleGalleryOgCover: new public endpoint that
    streams the hero thumbnail. Validates slug shape, checks the
    opt-in flag + hero presence + thumbnail existence; returns 404
    on any failure. ETag = thumbnail mtime + photo id so a
    regenerated thumb busts crawler caches. Cache-Control:
    public, max-age=300 (short — admins shouldn't wait an hour for
    a cover swap to land in chat previews).
  - server.js: mount the new GET /og/gallery/:slug/cover route. The
    existing nginx ^~ /og/gallery/ proxy block already covers it.
  - adminEvents.js: validator + persistence on POST + PUT.
    formatBoolean coercion so SQLite (0/1) and Postgres (boolean)
    both behave correctly.

Frontend:
  - Event type + UpdateEventData carry og_image_share_enabled.
  - EventDetailsPage adds a checkbox under the HeroPhotoSelector,
    disabled when no hero photo is picked. Help text deliberately
    spells out the public-by-design consequence — admins shouldn't
    flip this on for a sensitive gallery without realising what
    they're sharing with link-preview crawlers.

Tests: 8 new in galleryOgService.shareImage.test.js — pin the
cover-vs-logo decision contract (3 cases) plus the defensive
fallbacks (deleted hero, missing thumbnail) and the 404 contract
on the cover endpoint (4 cases). The 404 tests assert that
ensureThumbnail() is NOT called when opt-in is off, so a future
refactor can't accidentally widen the unauthenticated cover
endpoint to expose a hero the admin hasn't shared.

i18n: en + de hand-translated; nl + pt + ru + fr machine-translated
and flagged for native review per project convention.
2026-05-13 13:47:02 +02:00
Paul Nothaft 3122dd08a8 fix(customer-routes): Cache-Control: no-store on customer endpoints (#470)
The trigger: PR #458 mounted requireCustomerPortalEnabled which
410'd every /api/customer/* + /api/admin/customers/* request when
the master toggle was off. Some browsers cached that 410 (no
Cache-Control header was set, so heuristic freshness applied —
the wrong default for an authenticated/sensitive surface).
PR #470 reverted the middleware, but a customer whose tab cached
the 410 still saw 410s until they hard-refreshed.

Add noStoreCache middleware and mount it in front of both route
groups. Every response (200, 4xx, 5xx) now carries
`Cache-Control: no-store, no-cache, must-revalidate, private`
plus the HTTP/1.0 Pragma + Expires fallbacks. Any future
transient error from these endpoints can no longer get pinned in
browser or proxy caches and outlive its cause.

Cost is one setHeader per request; applied per route group rather
than globally so static assets + galleries keep their own caching
strategy unchanged.

Includes a dedicated unit test pinning the header set so a future
cleanup pass can't quietly drop it and re-introduce the bug.
2026-05-12 22:53:49 +02:00
Paul Nothaft 5e86eef4f8 test(gallery): verifyGalleryAccess customer-assignment revocation (#470)
4 unit tests pinning the contract of the customer-minted JWT
re-check added in #470:

- via='customer' + customerId, assignment present → next() runs.
- via='customer' + customerId, assignment removed → 403 with
  CUSTOMER_ASSIGNMENT_REVOKED code.
- customerId in payload but `via` claim missing → no re-check
  (defends against a future refactor accidentally widening the
  gate to match every legacy session that happens to carry a
  customerId field).
- per-event-password JWT (no via, no customerId) → no
  event_customer_assignments query at all (asserted by counting
  db() invocations — a regression that quietly added a re-check
  here would 403 every guest the moment any unrelated customer
  was unassigned from any event).

Same mock pattern as customerAuth.middleware.test.js. The re-check
is the load-bearing piece behind the "Manage galleries" dialog
UX promise — these tests guard it explicitly.
2026-05-12 22:53:23 +02:00
Paul Nothaft 7a9c4ca44e test(customers): unit-cover setAssignmentsForCustomer (#470 follow-up)
5 new tests covering the diff math (added/removed), the
archived-event filter, the no-op short-circuit when wanted equals
existing, and the type-coercion of the wanted-list input. Mirrors
the existing setAssignmentsForEvent suite shape so the inverse-
direction service function carries equivalent regression coverage.

This function is the writer behind the "Manage galleries" dialog
and the verifyGalleryAccess re-check together form the access-
control story for the whole feature — getting the diff math
wrong here means assignments don't actually revoke, which is the
entire promise of the new UI.
2026-05-12 22:53:05 +02:00
Luca c02c947463 feat(customers): email customer when admin adds new gallery access 2026-05-12 01:25:12 +02:00
Luca 3f4419356a revert(customer-portal): make the global flag UI-only, drop the kill-switch middleware 2026-05-12 00:40:17 +02:00
Luca 55a5846f6f feat(gallery): revoke customer-minted JWTs when assignment is removed 2026-05-11 23:39:47 +02:00
Luca 5377b88e0e feat(customers): replace-assignments endpoint for a single customer 2026-05-11 23:39:22 +02:00
Luca 2cae3fe47d feat(email-templates): categorise + sub-categorise + link to feature flags 2026-05-11 20:56:28 +02:00
Luca 84c06affb7 feat(email-templates): categorise + link to feature flags 2026-05-11 20:41:50 +02:00
Luca c0c6b4c0e8 chore(customer-portal): align flag-gate comments with new dual-enforcement 2026-05-11 19:38:18 +02:00
Luca 9091ed4012 feat(clients): scaffold top-level Clients section with sub-nav around Accounts 2026-05-11 16:17:14 +02:00
Luca 75e41eba03 feat(branding): toggle login-page logo frame + size 2026-05-11 11:02:33 +02:00
Luca dde72a1b1b fix(events): strip customer_account_ids from update spread 2026-05-11 10:53:12 +02:00
Paul Nothaft 49b36a0352 chore(migrations): renumber 090 → 096 + small notes from #403 review
Post-merge cleanups after #403 (customer portal):

- Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's
  090_add_customer_accounts ... 095_add_customer_portal_flag chain.
- customerAccountsService.js: TODO note on must_change_password
  documenting that the column is decorative until an admin
  pre-loaded-password flow ships (mirrors what adminAuth does for
  must_change_password today).
- customerAuth.js: doc-comment on the /login route explaining why the
  customerPortal feature flag deliberately doesn't gate it (toggle off
  hides UI, doesn't revoke existing-customer access; deactivate
  individual accounts to lock out).
- 095_add_customer_portal_flag.js: header comment said "Migration 094"
  (copy-paste from 094) — now matches the filename.
2026-05-11 09:59:47 +02:00
Paul Nothaft 936a277eb8 fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
The aspect-aware gallery layouts (masonry / mosaic / justified) read
photo.width and photo.height to size each card to the source's real
proportions. Two import paths were inserting rows without those
fields, which forced MasonryGalleryLayout to fall back to a hard-coded
800×600 default — every card came out the same shape, so users
reported masonry as "always cropped to 1:1ish" no matter which
thumbnail fit mode they chose.

- fileWatcher.js: extract dims with sharp.metadata() before insert.
- s3AutoImporter.js: same, materialising a tmp local copy via
  withLocalCopy so it works in S3 mode.
- migration 090: backfill any pre-existing rows with NULL dims
  (skips videos, skips S3 deployments — those need the writer fix
  alone since migrations cannot reach the storage backend).
- imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to
  'inside' (only kicks in when the seed setting is missing — existing
  installs keep their saved value). Add UI tooltip recommending
  'inside' for masonry/mosaic/justified, 'cover' for uniform grids.

i18n covers all six locales.
2026-05-11 09:58:08 +02:00
Luca b252cb67eb feat(branding): Customer dashboard header toggles in Branding page
Adds back the "Show logo" / "Show company name" toggles for the
customer dashboard, scoped to /customer/* surfaces only. Lives as a
dedicated card at the bottom of Settings → Branding, gated by the
customerPortal feature flag so admins who haven't enabled the portal
don't see it.

* Backend: restored GET/PUT /admin/settings/customer-surface
  endpoints, whitelisted only to the two branding keys
  (customer_show_logo, customer_show_company_name). The
  calendar/quotes/bills feature globals that used to live on this
  endpoint are now driven by the Features tab (feature_flags table).
* customerAccountsService.getCustomerSurfaceGlobals() reads from
  app_settings again so /api/customer/auth/session honours the
  toggles in its branding payload.
* New CustomerDashboardBrandingCard component with its own save
  flow — separate from the main BrandingPage payload so flipping a
  toggle doesn't replay the full branding mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 01:52:27 +02:00
Luca adfa29e91e fix(auth): restore COOKIE_SECURE='auto' default for production
The customer-portal squash inadvertently reverted the upstream/beta
fix from PR #427: production NODE_ENV was flipping the cookie Secure
flag back to hard `true`, which broke admin login on
HTTPS-frontend → HTTP-backend reverse-proxy stacks (browser drops
the Secure cookie over HTTP, login loops indefinitely).

Restored upstream/beta's tokenUtils.js verbatim and re-layered only
the customer cookie helpers (CUSTOMER_COOKIE_NAME,
setCustomerAuthCookie, clearCustomerAuthCookie,
getCustomerTokenFromRequest) on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:29:04 +02:00
Luca 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 00:05:20 +02:00
Paul Nothaft 3a731e7c95 feat(footer): hideable legal links + socials + promo banner (#441 + #440)
Combined footer overhaul:

- Per-CMS-page show_in_footer toggle (#441) — admins can hide
  Impressum / Datenschutz from the gallery footer when an external
  privacy / imprint URL is enough.
- Five social-media URL fields in branding settings (#441) — Facebook,
  Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each
  icon individually; the row is omitted when none are set.
- Promotional banner slot above or below the gallery footer (#440) —
  global default authored as markdown in branding settings, plus a
  three-way per-event override on the Edit Event form
  (inherit / custom / off). Backend nulls promo_markdown automatically
  when mode != 'custom' so stale text never persists.

Sanitization: marked with gfm/breaks → DOMPurify with a tight
allowlist (no img, no tables, no inline html). Post-process forces
target=_blank rel="noopener noreferrer nofollow" on every link so
admin-set URLs can't tab-nap the gallery context.

i18n covers all six locales (en/de/nl/pt/ru/fr).

Targets the beta branch.
2026-05-10 21:46:20 +02:00
Paul Nothaft e54456135c fix(events): admins can clear expiration on edit even when "Require expiration" is ON (#426)
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."

The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:

  Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
  Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected

The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.

Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.

Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.

Verified end-to-end with toggle ON:
  STEP 1: create with expiration → ok (unchanged)
  STEP 2: create without expiration → backend auto-applies default 30d
          (create-time enforcement intact)
  STEP 3: PUT {expires_at: null} on existing → "Event updated
          successfully" (was 400)
  STEP 4: DB column expires_at is NULL
  STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
          date input sends when cleared)

Smoke 13/13 green; no regressions.
2026-05-10 20:54:36 +02:00
Paul Nothaft 15e333681f feat(settings): Features tab + sidebar reorg with feature-flag gating
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.

Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.

Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
  Migration 088 detects existing-vs-fresh installs from the events
  table:
    * Existing install (events>0)  → all 9 flags TRUE so nothing
      vanishes from an admin's UI on upgrade.
    * Fresh install      (events=0) → spec defaults: galleries,
      reminderEmails, analytics, userManagement TRUE; calendar,
      calendarBooking, quotes, bills, messaging FALSE.

- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
  `settings.edit`. Server enforces the same dependency rules the
  frontend does (galleries always TRUE, quotes=false → bills=false,
  calendar=false → calendarBooking=false). PUT writes one
  `feature_flags_updated` activity log row with the diff.

Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
  reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
  AdminLayout so flag fetches carry the auth cookie. Source of truth
  is the server response; staged is a local copy that the Features tab
  edits and the Save button PUTs.

- `RequireFeature` route guard for /admin/analytics and /admin/users —
  redirects to /admin/dashboard when the corresponding flag is OFF.

- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
  Branding, Event Types, Backup, CMS Pages (now Settings tabs).
  Feature-gated: Analytics, Users.

- Old top-level routes (/admin/email, /admin/branding, /admin/event-
  types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
  /admin/settings?tab=<key> so existing bookmarks don't 404.

- SettingsPage rewritten with a 6-group inner-nav (General /
  Content & Appearance / Communication / Privacy & Security /
  Integrations / System) and 19 tabs. New Features tab is the
  default landing tab. URL ?tab=<key> roundtrips with state — deep
  links and the back button work.

- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
  Analytics + User Management (the two flags that gate sidebar items
  in this PR). All other toggles disabled with a "Not yet available"
  lockedReason — the cards still render so admins see the roadmap, but
  the flag has no UI effect until the surface ships in its own PR. The
  galleries card is locked TRUE per spec (foundation, can't be off).

- Live SidebarPreview reflects unsaved staged changes — admins see
  what their sidebar will look like before they save.

- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
  Features tab copy, the new Settings group labels, and the lifted
  tab titles.

Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
  set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
  defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
  rule enforced (bills forced false when quotes=false even when bills=
  true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
  redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
  Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
  enabled, toggling Analytics off + saving updates the sidebar +
  redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
2026-05-10 20:36:32 +02:00
Paul Nothaft 83d79f4d39 fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.

The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.

Changes:

- Add getRange(relPath, start, end) to the StorageBackend interface +
  LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
  (downloadStream with Range header). Needed for video range requests
  on S3 — previously the photo route did fs.createReadStream(filePath,
  {start, end}) which is local-only.

- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
  via storage.get. Watermark application path materializes the source
  via withLocalCopy (no-op in local mode, downloads to a tmp file then
  cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
  works.

- /:slug/photo/:photoId — branches on source_origin: external/reference
  photos still use the local fs path (NAS mounts are local), managed
  photos use the storage abstraction. Video range requests pass through
  to storage.getRange. Pre-generated watermarks served via storage too.
  On-the-fly watermark generation uses withLocalCopy for managed photos.

- /:slug/hero/:photoId — hero images are always managed-storage keys
  (imageProcessor.generateHeroImage writes via the storage abstraction),
  so this just switches to storage.stat + storage.get. Watermark via
  withLocalCopy.

Verified end-to-end against minio in dev:
  POST /api/admin/photos/N/upload         → photo + thumbnail land in S3
  GET /api/gallery/<slug>/thumbnail/<id>  → 200, JPEG 300x300 ✓
  GET /api/gallery/<slug>/photo/<id>      → 200, JPEG 1200x800 ✓
  GET /api/gallery/<slug>/hero/<id>       → 200, JPEG 1920x1080 ✓
  ETag round-trip (If-None-Match)         → 304 ✓
  Backend logs                            → no errors

LocalFs regression: 13/13 smoke tests pass.

Closes #432.
2026-05-09 20:56:20 +02:00
PiR1 916580adef fix(event): ensure client share token is generated only when necessary 2026-05-09 20:28:16 +02:00
PiR1 d00f6fa7de fix(event): correct updating client access 2026-05-09 20:26:55 +02:00
Paul Nothaft e1c93823c4 Merge pull request #429 from the-luap/fix/cookie-secure-auto-default-427
fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
2026-05-09 16:00:19 +02:00
Paul Nothaft e2ffd9f93d Merge pull request #424 from the-luap/fix/external-thumbnails-423
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
2026-05-09 15:59:17 +02:00
Paul Nothaft 5c7de96b7f fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
Two intertwined bugs reported in #427 by @iSchumi6210:

1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true
   when NODE_ENV=production. Over plain HTTP the browser drops the Secure
   cookie → next /auth/session request returns 401 → redirect back to
   /admin/login → no error shown. picpeak-setup.sh writes
   NODE_ENV=production but never writes COOKIE_SECURE, so every first-time
   install without a reverse proxy hits this.

2. Admin password is generated but admins can't find it. The 001_init.js
   migration writes the generated password to data/ADMIN_CREDENTIALS.txt
   inside the backend container, but picpeak-setup.sh only copies it out
   when --reset-admin-password is passed. Default-path users never see it
   and resort to manual bcrypt updates in psql.

Changes:

- tokenUtils.js: production default goes from `true` to `'auto'`. On real
  HTTPS req.secure is true → Secure flag is still emitted (no security
  regression for reverse-proxy deployments). On plain HTTP req.secure is
  false → Secure flag omitted → login works. Users who explicitly want
  the strict HTTPS-only behaviour can still set COOKIE_SECURE=true.

- .env.example: rewrite the COOKIE_SECURE block to make the new default
  obvious and explain when to override (set =true for strict, =false to
  skip the per-request check, leave unset for the auto behaviour).

- picpeak-setup.sh (both Docker and native paths):
  - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in
    depth so the right behaviour is preserved even if the backend default
    flips again later)
  - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the
    backend container/data dir to the host data dir, chmod 600, and print
    the email + password to the install output. The credentials file
    remains as a backup record that the operator should delete after
    noting the password.

Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE:

  production, unset      → HTTPS: secure=true ✓  HTTP: secure=false ✓ (was both true)
  production, =true      → both: secure=true (strict opt-in preserved)
  production, =auto      → HTTPS: secure=true   HTTP: secure=false (already-correct)
  development, unset     → both: secure=false (dev unchanged)
2026-05-09 15:55:09 +02:00
Paul Nothaft f3d0f161c9 fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.

Two halves:

1. import-external route generates the thumbnail right after each
   successful insert and writes thumbnail_path on the row. Best-effort:
   a single failure logs a warning and leaves thumbnail_path=NULL —
   ensureThumbnail will retry lazily on first view. Synchronous in the
   loop adds ~100-300ms per image; for the worst-case 1000-photo import
   that's still under the typical request timeout.

2. ensureThumbnail() in imageProcessor handles external photos too —
   resolves the local NAS mount path via resolvePhotoFilePath instead of
   the storage-backend key. This covers existing externals already in
   the database that were imported before this fix: first gallery view
   per photo regenerates the thumbnail, subsequent views are fast.

Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.

Verified locally with a 3-photo external dir and a real NAS-style import:
  POST /api/admin/external-media/events/N/import-external
  → {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
  /api/gallery/<slug>/photos returns thumbnail_url for every photo
  Lazy-regen path: clearing thumbnail_path + deleting the file, then
  hitting /thumbnail/N regenerates and repopulates the row in 42ms.

Closes #423.
2026-05-08 19:17:15 +02:00
Paul Nothaft 9326a427b3 Merge pull request #420 from the-luap/fix/update-notification-test-email-418
fix(admin): test email always sends, regardless of update availability (#418)
2026-05-08 10:15:01 +02:00
Paul Nothaft c2b1854df6 fix(admin): test email always sends, regardless of update availability (#418)
The "Send Test Email" button on the Update Notifications settings page
called sendUpdateNotificationNow() — which bailed out with "No updates
available" when the instance was already on the latest version. Admins
on a current install had no way to verify their SMTP / recipient list
was working until an update happened to be pending. Reported in #418
by @Rekoo-PS.

Changes:

- Add migration 087: insert a dedicated `version_update_test` email
  template (EN + DE, matching the existing version_update_available
  convention) with copy that reads as a config-check rather than as a
  real update notice. Subject prefixed with [TEST] so it's unambiguous
  in the inbox. Variables: current_version, channel, recipient_email.

- Replace sendUpdateNotificationNow() with sendTestUpdateNotification()
  in updateNotificationService.js. The new path:
    - Always sends — no updateAvailable bail-out.
    - Uses the version_update_test template.
    - Falls back gracefully if checkForUpdates fails (so a transient
      GitHub API hiccup doesn't block a config-check email).
    - Does NOT update last_notified_version — that field stays owned by
      the real-update path so a test send doesn't shadow a future
      genuine notification for the same version.

- Wire /admin/system/updates/notifications/send to the renamed function.
  No frontend change needed (the button already calls this endpoint).

Verified locally with the dev mailhog: clicking Send Test Email on a
3.42.3-beta.0 instance (which has no pending update) delivers 4 emails
to all admin recipients with subject "[TEST] PicPeak Update Notification
— configuration check" and body interpolated correctly. Returns
{success: true, successCount: 4, ...} — previously would have returned
{success: false, message: "No updates available"}.
2026-05-08 09:57:31 +02:00
Paul Nothaft 99e420b1b9 fix(events): typed-DELETE confirmation for bulk delete (#417)
The bulk-delete modal previously used a password input as a confirmation
gate, with an Enter-to-submit handler. Windows Hello / passkey flows
that target password fields were able to autofill and synthesise an
Enter keystroke, which submitted the form and triggered the destructive
delete without an explicit click on the red Delete button (Rekoo's
report in #417).

Replace the password gate with a GitHub-style typed-literal pattern:
the user types the literal "DELETE" (English, case-sensitive) into a
plain text input. The Delete button stays disabled until the input
matches, and there is no Enter-to-submit handler — only an explicit
click on the red button proceeds. Plain text inputs aren't subject to
password autofill or passkey ceremony so the auto-submit class of bug
is gone.

Server side, drop the bcrypt password verify on /admin/events/bulk-delete
and the related INVALID_PASSWORD response. The server's auth boundary
remains adminAuth + requirePermission('events.delete'); this matches
DELETE /admin/events/:id which has never required a re-entered password.
The client-side typed gate is the safeguard against accidental clicks.

i18n: drop password-related keys, add confirmLabel + confirmHelp across
en, de, nl, pt, ru. The literal "DELETE" stays English in all locales
to keep the gesture immune to translation drift and unambiguous.

Verified locally: typed-DELETE sanity spec covers the gate (wrong case
disabled, correct enables, Enter-on-input no-ops, click submits, events
deleted). Existing 03-bulk-archive smoke remains green.
2026-05-08 09:46:24 +02:00
Luca 47b6b39f3a feat(email): expand email palette to 8 tokens + Sync from Branding button 2026-05-06 01:41:45 +02:00