Compare commits

..

561 Commits

Author SHA1 Message Date
Paul Nothaft 7491eb21c4 Merge pull request #388 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.37.0-beta.0
2026-05-04 20:59:53 +02:00
github-actions[bot] 67999999d8 chore(beta): release 3.37.0-beta.0 2026-05-04 18:52:09 +00:00
Paul Nothaft d561db802b Merge pull request #387 from the-luap/feat/events-photos-column
feat(events): add Photos column to admin events list (#384)
2026-05-04 20:51:44 +02:00
Paul Nothaft ffb4318a1f feat(events): add Photos column to admin events list (#384)
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.

- Insert a "Photos" column between Date and Status — groups with
  the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
  column.
- Reuses the existing `events.photos` i18n key already shipped in
  all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
  no new translations needed.
- Updates the empty-state colSpan from 7 to 8.

Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
2026-05-04 20:47:13 +02:00
Paul Nothaft 0adec25fe7 Merge pull request #382 from Luca-Timo/refactor/external-media-types
refactor(events): type external-media list response, drop any casts
2026-05-04 14:46:35 +02:00
Luca d823dcda26 Merge branch 'beta' into refactor/external-media-types 2026-05-04 14:40:01 +02:00
Paul Nothaft 4d99eb672d Merge pull request #383 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.36.0-beta.0
2026-05-04 13:44:35 +02:00
github-actions[bot] 66281f9790 chore(beta): release 3.36.0-beta.0 2026-05-04 11:33:17 +00:00
Paul Nothaft 3fe8e61bd1 Merge pull request #379 from Luca-Timo/feat/admin-email-picker
feat(events): prefill admin email + admin picker on event creation
2026-05-04 13:32:53 +02:00
Paul Nothaft 06f2b75ec3 Merge pull request #381 from the-luap/chore/external-folder-picker-types
chore(events): type FolderTreeNode entries with ExternalEntry
2026-05-04 13:31:11 +02:00
Luca ffbb0e659f refactor(events): type external-media list response, drop any casts 2026-05-04 12:28:46 +02:00
Paul Nothaft 98c6f6cf06 chore(events): type FolderTreeNode entries with ExternalEntry
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.

- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
  'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
  ExternalEntry from the typed `dirs` array.

No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
2026-05-04 12:24:21 +02:00
Paul Nothaft bfd7169fc4 Merge pull request #380 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.35.0-beta.0
2026-05-04 12:21:35 +02:00
github-actions[bot] 7e4f1acb88 chore(beta): release 3.35.0-beta.0 2026-05-04 10:21:13 +00:00
Paul Nothaft cdd40acb45 Merge pull request #378 from Luca-Timo/beta
feat(events): tree view for external media folder picker
2026-05-04 12:20:54 +02:00
Luca 40d23e24cb i18n(events): translate admin email picker strings (nl/pt/ru) 2026-05-04 12:10:23 +02:00
Luca ee56b6762f feat(events): prefill admin email + admin picker on event creation 2026-05-04 12:10:23 +02:00
Luca bd42ee1ce0 fix(events): match scrollbar to theme in external folder tree picker 2026-05-04 11:30:46 +02:00
Luca 954ab00495 i18n(events): translate external folder picker strings (nl/pt/ru) 2026-05-04 11:02:00 +02:00
Luca f927b09c70 feat(events): tree view for external media folder picker 2026-05-04 11:01:49 +02:00
Paul Nothaft 048870804f Merge pull request #377 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.2-beta.0
2026-05-04 09:15:29 +02:00
github-actions[bot] 13e3a05386 chore(beta): release 3.34.2-beta.0 2026-05-04 07:14:40 +00:00
Paul Nothaft 3ab8a64a24 Merge pull request #376 from the-luap/fix/ffmpeg-on-alpine
fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
2026-05-04 09:14:16 +02:00
Paul Nothaft 96818c7ae8 fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.

Two compounding causes:

1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
   dependency added with the video-support PR (commit 68a9dc5)
   ships per-platform binaries via optionalDependencies. The Linux
   binaries are built against glibc, but the backend image runs on
   `node:22-alpine` (musl libc) — known to either fail to execute
   or fail on shared-library lookups on Alpine.

2. **`ffprobe` missing entirely.** `@ffmpeg-installer/ffmpeg`
   bundles only the `ffmpeg` binary. There's a separate
   `@ffprobe-installer/ffprobe` package that the codebase never
   depended on. But `videoProcessor.js:21` calls
   `ffmpeg.ffprobe(videoPath, …)` — the very first step of the
   video pipeline shells out to a `ffprobe` binary that doesn't
   exist in the image. Even if (1) worked, every video upload
   would 500 here.

The fix is to install Alpine's `ffmpeg` package via apk. It ships
both `ffmpeg` and `ffprobe` built natively against musl, ~70MB
extra image size, single line in the Dockerfile, no per-arch
handling needed (apk pulls the right binary for both linux/amd64
and linux/arm64 — works with the multi-arch infra from #349).

- `backend/Dockerfile`: add `ffmpeg` to the apk install line.
- `backend/Dockerfile.dev`: same for dev parity.
- `backend/src/services/videoProcessor.js`: remove the
  `setFfmpegPath(require('@ffmpeg-installer/ffmpeg').path)` line
  — without removing it, fluent-ffmpeg would prefer the broken
  bundled binary over the working apk one. Letting fluent-ffmpeg
  fall back to PATH lookup picks up the apk binary in the
  container and the developer's locally-installed binary on dev
  hosts (Homebrew on macOS, apt on Debian).
- `backend/package.json`: drop the now-unused
  `@ffmpeg-installer/ffmpeg` dependency. `npm install` removes
  2 packages from the lockfile.

Verified: `videoProcessor.js` still loads cleanly (`node -e
"require('./src/services/videoProcessor')"`); lint clean.
2026-05-04 09:04:38 +02:00
Paul Nothaft 8f2639bdca Merge pull request #375 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.1-beta.0
2026-05-04 00:19:37 +02:00
github-actions[bot] eb6791792e chore(beta): release 3.34.1-beta.0 2026-05-03 22:18:02 +00:00
Paul Nothaft 08d046276b Merge pull request #374 from the-luap/fix/cms-external-url-i18n-and-api-shape
fix(cms): nl/pt/ru i18n + gate external_url in public response
2026-05-04 00:17:40 +02:00
Paul Nothaft bce5c1f725 fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):

1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
   locales but the project ships 5 locales total. Adds the missing
   nl / pt / ru translations so the admin CMS page renders in the
   active language for those users instead of falling back to
   English literals next to the German/Dutch/Portuguese/Russian
   surrounding strings.

2. **API shape.** `publicCMS.js` returned `external_url`
   unconditionally — even when `use_external_url` is false the URL
   value was still emitted in the public response. The frontend
   correctly gated on both flags so it worked, but the API surface
   was leaking a value the admin had explicitly disabled. The
   value still lives in the DB (so the toggle can be flipped back
   on without losing it), but the public endpoint now returns
   `null` whenever the toggle is off.

   Note: kept the existing `logo_url` shape unchanged. Its semantics
   are different — null means "fall back to global branding" and
   consumers rely on always having the field, so emitting it
   unconditionally is intentional there.

No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
2026-05-04 00:14:07 +02:00
Paul Nothaft ccd3fd9349 Merge pull request #373 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.34.0-beta.0
2026-05-04 00:11:41 +02:00
github-actions[bot] cf04c0b2dc chore(beta): release 3.34.0-beta.0 2026-05-03 22:11:01 +00:00
Paul Nothaft b2c8161a43 Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
2026-05-04 00:10:44 +02:00
Paul Nothaft aade003564 Merge pull request #371 from the-luap/i18n/password-reset-modal
i18n(events): translate PasswordResetModal across 5 locales
2026-05-03 23:29:09 +02:00
Paul Nothaft c270bcfc9f i18n(events): translate PasswordResetModal across 5 locales
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.

- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
  keys covering both modal screens, the warning banner, validation
  errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
  string. Reuses `common.cancel`, `events.copy`, `events.copied`
  where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
  syntax so the description line reads naturally in each language.

No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
2026-05-03 23:16:21 +02:00
Luca c5bba505ac feat(cms): redirect legal links to external URL when configured 2026-05-03 23:06:16 +02:00
Luca a4e3d10fb0 feat(cms): admin UI for external imprint/privacy URL 2026-05-03 23:05:51 +02:00
Luca 66423bb65e feat(cms): add per-page external URL override — backend 2026-05-03 23:05:18 +02:00
Paul Nothaft c0ca5ed687 Merge pull request #370 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.2-beta.0
2026-05-03 22:49:02 +02:00
github-actions[bot] d552f45b20 chore(beta): release 3.33.2-beta.0 2026-05-03 20:48:36 +00:00
Paul Nothaft 0d1f82d31a Merge pull request #369 from the-luap/fix/admin-set-password-and-full-url-emails
fix(events): admin-set password on reset, full-URL gallery_link in all emails
2026-05-03 22:48:17 +02:00
Paul Nothaft ff50c74e19 fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367
opened, addressed together:

1. Reset-password endpoint was a one-way auto-generate.
   `POST /admin/events/:id/reset-password` always called
   `generateReadablePassword()` and ignored any client-supplied value;
   the modal only offered a confirm + a forced auto-generated result.
   Admins who wanted to set a memorable customer-supplied password
   had no way to do it.

   Backend: route now reads optional `password` from the body. If
   present, validates with `validatePasswordInContext('gallery', …)`
   (same rules as create-event) and uses it; if absent, falls back to
   the existing generator, so old callers / cron stay functional.
   Switched the bcrypt rounds from a hard-coded `10` to
   `getBcryptRounds()` to match the create flow.

   Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with
   show/hide, confirm-password field that appears on type, the same
   `<PasswordGenerator>` used by `CreateEventPage` (event-context-
   aware, fills both fields when used), send-email checkbox,
   client-side validation, server-side validation feedback inline.
   Submit empty → server auto-generates and the success screen shows
   the value with a copy button (legacy one-click flow preserved);
   submit with a typed password → success toast + close (no need to
   re-show what the admin already typed).

   Service layer: `events.service.resetPassword(id, sendEmail,
   password?)` only sends `password` in the body when set.

   Caller: `EventDetailsPage` now passes `eventDate` + `eventType`
   into the modal so the generator has event context.

2. `gallery_link` was the path-only `event.share_link` in three
   email-queue sites, so customer mail showed
   `/gallery/<slug>/<token>` instead of the full
   `https://example.com/gallery/<slug>/<token>` URL.

   - `adminEvents.js` reset-password queue (#1437)
   - `adminEvents.js` resend-creation-email queue (#1502)
   - `expirationChecker.js` expiration_warning queue (#82)

   All three now derive `shareUrl` from `buildShareLinkVariants`
   (the same helper already used by create-event, publish-from-
   draft, and event-rename). The other 4 callers
   (`adminEvents.js:651/913`, `events.js:187`,
   `eventRenameService.js:231`) already used the full URL — this
   closes the gap.

Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on
every touched file (the 4 lint errors that remain in
`adminEvents.js` are pre-existing and predate this branch).
2026-05-03 22:44:22 +02:00
Paul Nothaft d4d8833d82 Merge pull request #368 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.1-beta.0
2026-05-03 22:12:19 +02:00
github-actions[bot] 65d8032960 chore(beta): release 3.33.1-beta.0 2026-05-03 20:10:16 +00:00
Paul Nothaft 07672038d4 Merge pull request #367 from the-luap/fix/email-template-rendering-and-caller-data
fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
2026-05-03 22:09:58 +02:00
Paul Nothaft e8052adf1d fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.

Renderer (`backend/src/services/emailProcessor.js`)

- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
  before flat `{{var}}` substitution. The shipped templates have used
  Handlebars-style conditionals since migration 026; the renderer
  ignored them, so the markers leaked verbatim into every mail with
  an empty welcome_message. Lifted to module scope and exported so
  the conditional contract is unit-testable. Single-pass, non-nested
  (commented).

- Added `passwordSetAtCreationI18n` next to the existing two i18n
  password sentinels so `(set at creation)` (sent by the publish-
  from-draft flow when only the bcrypt hash remains) is localised
  to "Das bei der Erstellung der Galerie gesetzte Passwort" /
  equivalent in EN/DE/NL/PT/RU instead of the raw English string.

- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
  so admin-supplied free text (`event_name`, `host_name`, …) is
  HTML-escaped on substitution into the HTML body. Allowlist of
  passthrough keys (`welcome_message` already-HTML, server-generated
  URLs `gallery_link` / `client_link`). Subject and text body keep
  the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
  before nl2br so the welcome_message allowlist is safe.

- New `htmlToText()` strips `<style>` and `<script>` blocks (and
  their content) before tag-stripping, decodes common entities, and
  collapses whitespace. Used by the textBody fallback in
  `sendTemplateEmail` — without this, every template missing a
  `body_text` produced a "plain-text" mail starting with the 100+
  lines of CSS embedded by `wrapEmailHtml()`.

- The client-access section (#172) now mirrors its HTML block into
  `textBody` using the same per-language strings, so plain-text
  recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
  into `clientAccessI18n` (RU uses ПИН-код).

- Added `getSupportEmail()` exported helper that reads
  `branding_support_email` from `app_settings` (JSON-decoded), with
  the SMTP from-address as fallback. Used by the gallery_expired and
  archive_complete callers below.

- Removed dead `require('handlebars')` (unused since the regex
  renderer landed; pre-existing lint error in this file).

Callers (data the templates already reference)

- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
  (templates use this, the old code sent `expiration_date` —
  typo'd key, never read), drop the hard-coded `.de`/`en` sniff
  (the processor formats with the recipient's resolved language),
  add the `{{password_security_message}}` sentinel for
  `gallery_password` (plaintext is gone by warning time, so
  customers used to see literal `{{gallery_password}}` in the mail).

- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
  now supply `host_name`, `event_date`, `expiry_date`,
  `support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
  doesn't render literal `{{host_name}}, your gallery expired on
  {{expiry_date}}`. Skip the duplicate admin send when
  admin_email == customer_email.

- `archiveService.js`: `archive_complete` queue now supplies
  `host_name`, `photo_count` (from `photoEntries.length`),
  `archive_date`, `support_email` — the previous payload had only
  `event_name` and `archive_size`, so most of the mail was
  unfilled placeholders.

Tests

- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
  16 cases — flat substitution, conditional truthy/falsy/missing/
  multi-line/sibling/numeric-0, plus 5 cases for the new
  `escapeHtml` option (default off, escape on, allowlist
  passthrough for welcome_message and gallery_link).

- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
  the regression scenario (full wrapped body with embedded `<style>`
  block), tag-stripping, entity decoding, paragraph spacing.

- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
  `nl2br`, and the now-escaping `formatWelcomeMessage`.

35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
2026-05-03 22:06:06 +02:00
Paul Nothaft 297960698a Merge pull request #365 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.33.0-beta.0
2026-05-02 23:04:13 +02:00
github-actions[bot] 4207207c5e chore(beta): release 3.33.0-beta.0 2026-05-02 21:02:15 +00:00
Paul Nothaft df3061893d Merge pull request #349 from Luca-Timo/feat/apple-silicon-support
feat: native multi-arch Docker images (Apple Silicon, ARM64 Linux)
2026-05-02 23:01:54 +02:00
Paul Nothaft 4765804645 Merge pull request #364 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.5-beta.0
2026-05-02 22:59:17 +02:00
Paul Nothaft 907bcf1eb2 Merge pull request #363 from the-luap/feat/upload-redesign-and-auth-loop-fix
feat(upload): async photo processing + fix(auth): /auth/session symmetry (loop fix)
2026-05-02 22:59:03 +02:00
github-actions[bot] 214d8a1899 chore(beta): release 3.32.5-beta.0 2026-05-02 20:59:02 +00:00
Paul Nothaft f529c9e3d7 Merge pull request #362 from the-luap/fix/issue-358-theme-aware-skeleton
fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
2026-05-02 22:58:43 +02:00
Paul Nothaft b30010eb6f test(upload): unit tests for backgroundProcessor + processPhoto
Cover the two new pieces of the async pipeline:

backgroundProcessor.claimNextPhoto
  - returns null when no pending rows
  - returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
  - returns null when SQLite UPDATE-with-guard loses the race
  - returns the row when the SQLite guard wins

photoProcessor.processPhoto
  - happy path: writes thumbnail / dimensions / EXIF capture date and
    marks 'complete'; fires watermark queue + photo.uploaded webhook
    with the right payload
  - video path: writes ffmpeg duration / codec / dimensions; does NOT
    queue watermark (image-only)
  - throws cleanly when the photo row no longer exists

Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
2026-05-02 22:56:04 +02:00
Paul Nothaft 3b827b80d5 feat(upload): async photo processing — frontend (PR-B part 2)
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.

services/uploads.service.ts (new)
  - getStatus(uploadId)        — JSON snapshot from /admin/uploads/:id/status
  - retryPhoto(photoId)        — POST /admin/photos/:id/retry
  - streamUrl(uploadId)        — SSE upgrade URL

hooks/useUploadProgress.ts (new)
  - Tracks N concurrent upload IDs (one per chunk POST) and merges
    counters into a single aggregate.
  - Always polls every 1.5s; opportunistic SSE upgrade on top of that.
    SSE failure (proxy buffering, etc.) silently downgrades to polling
    only — no reconnect storms.
  - Auto-stops both channels when every tracked group is in a terminal
    (complete/failed) state.

components/admin/PhotoUpload.tsx
  - Captures upload_id from each chunk's 202 response, feeds them into
    useUploadProgress.
  - Phase machine extended: stays in 'processing' until the worker
    drains the queue (not just until bytes-on-wire). Progress UI shows
    real "X of N done" with a determinate bar fed by the aggregate.
  - "You can leave this page" hint kept — closing the modal is now
    actually safe, work continues server-side.
  - Side-effect refactor: invokes onUploadComplete twice — once early
    so the user sees photos appearing immediately, once on terminal
    so the parent grid sees final state.

components/admin/AdminPhotoGrid.tsx
  - Photos with processing_status pending/processing render an amber
    placeholder card with a spinning Cog instead of the missing
    thumbnail.
  - Photos with status='failed' render a red card with the error message
    and a "Retry" button that POSTs /admin/photos/:id/retry.

pages/admin/EventDetailsPage.tsx
  - Photo list query gains refetchInterval that polls every 2s while
    any photo is non-terminal, then stops. Keeps the grid auto-fresh
    during ongoing processing.
2026-05-02 22:56:04 +02:00
Paul Nothaft 851744c3c4 feat(upload): async photo processing — backend (PR-B part 1)
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.

Schema (migration 085_async_photo_processing.js):
  - photos.processing_status     enum default 'complete' (existing
                                 rows are already done)
  - photos.processing_error      populated on 'failed'
  - photos.processing_started_at timestamp for janitor recovery
  - photos.upload_id             groups all photos from one upload
                                 request so the frontend can poll
                                 status by group
  - indexes on processing_status and upload_id for queue lookups

services/photoProcessor.js
  - queueFilesForProcessing(files, options) — shared helper used by
    the admin and gallery upload routes. Moves files to final storage
    + inserts pending rows; returns { uploadId, photos, errors }.
  - processPhoto(photoId) — worker-mode: reads original from storage
    via withLocalCopy (transparent local/S3), generates thumbnail and
    EXIF/dimensions or video metadata, queues watermark, fires
    photo.uploaded webhook, marks 'complete'. Throws => caller marks
    'failed' with the error message.
  - processUploadedPhotos kept untouched — chunkedUploadService still
    uses the synchronous path.

services/backgroundProcessor.js (new)
  - N independent worker loops per backend instance (default 2,
    UPLOAD_PROCESSOR_CONCURRENCY env override).
  - Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
    UPDATE-with-status-guard. Pods race on rows, exactly one wins.
  - Janitor every minute resets photos stuck in 'processing' for >10
    minutes (worker died, pod restarted) back to 'pending'.
  - UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
  - Started from server.js after the other long-running workers.

routes/adminPhotos.js — POST /:eventId/upload
  - Replaced batch-of-25 sync processing loop with per-file
    move-to-storage + insert-pending. Response is now 202 with
    upload_id, count, photo_ids in addition to the legacy
    successCount / replacedCount fields the existing frontend reads.
  - Per-request temp directory cleanup is now a single idempotent
    handler on res.finish/res.close (was three inline blocks for
    error paths only, leaking dirs on success — original bug from
    contributor analysis).
  - GET /uploads/:upload_id/status — JSON snapshot of pending /
    processing / complete / failed counts plus per-photo state.
  - GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
    every 1.5s, emits on snapshot change, ends when all photos
    reach a terminal state.
  - POST /photos/:photoId/retry — flips a 'failed' photo back to
    'pending' so the worker picks it up again.
  - GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
    while the photo is still pending/processing, and 422 on 'failed'.
    The admin grid renders placeholders accordingly.

routes/gallery.js — POST /:eventId/upload (guest)
  - Refactored to use queueFilesForProcessing instead of the synchronous
    processUploadedPhotos. Same 202 + upload_id shape.
  - GET /:slug/photos now filters processing_status to 'complete' (or
    NULL for pre-migration rows) so guests never see in-flight photos.

Side-effect timing change:
  - photo.uploaded webhook now fires from the worker after the photo
    is actually processed (thumbnail + dimensions populated) instead
    of from inside the upload request. Same payload fields. Worth a
    one-line note in the changelog.
2026-05-02 22:56:04 +02:00
Paul Nothaft 86dfcc4f11 feat(upload): two-state UI + temp dir cleanup (PR-A of async processing)
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.

1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)

   When axios.onUploadProgress reports loaded === total, the request is
   on the server and the bytes have left the browser. Today the bar sits
   at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
   minutes on NFS-backed storage) and users assume the upload froze.

   The component now distinguishes two phases:
   - 'transferring' — bytes-on-wire, determinate progress bar.
   - 'processing'   — bytes done, waiting for response. Indeterminate
                      spinner + an explanatory hint that the backend is
                      generating thumbnails / reading metadata and the
                      user can leave the page.

   Same pattern in UserPhotoUpload (gallery): the per-file checkmark
   icon is replaced by a Loader2 spinner while the request is in flight
   after bytes-on-wire finished.

2. Temp directory cleanup (adminPhotos.js)

   Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
   are individually unlinked after they're moved to storage on the
   success path, but the empty directory was never removed. On error
   paths three different inline blocks each tried to clean up; the
   success path was missed entirely. Result: the orphan-empty-dirs
   accumulation reported in the issue (70+ on the affected instance).

   Replace the inline cleanup blocks with a single idempotent
   cleanupTempDir() registered on res.finish + res.close, so it fires
   exactly once on every exit path (validation 4xx, server 5xx, multer
   error, success).

New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
2026-05-02 22:56:04 +02:00
Paul Nothaft f905f7e733 fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355. The frontend trusts /auth/session as the source of
truth for "is the user authenticated?". When that endpoint is more
lenient than the protected middleware, every admin endpoint 401s
right after /auth/session said valid:true, the response interceptor
hard-redirects to /admin/login, /auth/session says valid again, and
the cycle closes — exactly the loop reported on v3.32.4-beta.0.

#355 fixed the issuer-claim asymmetry. This commit fixes the
remaining asymmetries: /auth/session was missing the admin-existence,
admin-active, password-change-after-iat, and gallery-existence /
gallery-archived / gallery-expired checks that adminAuth and
galleryAuth perform on every protected request.

The fix is to mirror those checks in /auth/session, scoped by token
type, and degrade gracefully when the underlying tables aren't
present (test fixtures, early bootstrap) so the endpoint never
fails-closed because of a missing table.

Reproducer that the new test covers:
  1. Admin logs in (token issued at T).
  2. Admin (or another admin) changes their own password at T+1.
  3. Browser still has the cookie from T.
  4. /auth/session says valid:true (no password-change check).
  5. /admin/dashboard fires queries; adminAuth rejects with
     PASSWORD_CHANGED 401.
  6. Frontend redirects to /admin/login.
  7. /auth/session says valid:true again. → loop.

Other surfaces this also covers:
  - admin user deactivated (admin_users.is_active = false)
  - admin user deleted
  - gallery token whose event is archived
  - gallery token whose event has expired

Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases,
mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout
so the suite runs without a real database.
2026-05-02 22:56:04 +02:00
Paul Nothaft 7b2f75e6ae chore: bump @playwright/test to ^1.57.0; drop unused root dotenv
The root devDependencies still pinned an older Playwright (1.48.2)
plus a stray `dotenv` that nothing in the e2e suite or root scripts
actually requires (verified via grep across tests/). Updates the
Playwright version to match the current upstream stable and removes
the unused dotenv to keep the root install lean.

Originated from a local stash that picked up these changes; landing
them as a small dedicated commit so they don't blend into the auth
fix that follows.
2026-05-02 22:56:04 +02:00
Paul Nothaft 1a530aeaa2 fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
Two further fixes for the gallery loading sequence shown in
@Rekoo-PS's frame breakdown on issue #358 — both about colours that
didn't track the active theme.

1. Initial white frame (frame f1)

   The pre-React bootstrap script in #359 sets the cached background
   on documentElement, but the browser may paint the very first frame
   *before* that <script> tag runs (synchronous parse-time JS in the
   <head> is still slightly later than CSS apply-time). On first-visit
   dark-OS devices that meant a single white frame before the script
   resolved.

   Fix: move the OS-preference default into a <style> block that
   precedes the script. CSS @media (prefers-color-scheme) is applied
   before paint, so dark-OS devices land on dark from frame zero.
   The script keeps the per-gallery cache hit on top, and now also
   stamps the colour onto document.body in case the body element has
   already mounted by the time the script runs.

2. "Most annoying" skeleton tile frame (frame f4)

   Skeleton placeholders rendered as bright `bg-neutral-200` light grey
   regardless of theme. On a dark gallery that's the highest-contrast
   thing on screen during loading — the exact frame Rekoo-PS labelled
   "the most annoying" in the issue.

   Fix: the Skeleton component's background now reads
   `var(--color-surface-border)`, which ThemeContext already wires up
   per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per-
   event themes can override). The bare `<div>` no longer carries any
   colour utility class — the inline style supplies the active value.
   Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable
   in favour of `var(--color-surface)` for the same reason.

Tests:
   New src/components/common/__tests__/Skeleton.test.tsx covers
   - Skeleton uses var(--color-surface-border, ...)
   - bg-neutral-200 is no longer present
   - SkeletonGalleryGrid tiles all inherit the theme colour
   - SkeletonCard surface uses var(--color-surface)
2026-05-02 22:52:09 +02:00
Luca c7ac9ddfe5 refactor: rename mac override to amd64 override for arch accuracy 2026-05-02 01:34:46 +02:00
Luca 3440ecc999 ci: lowercase image names for GHCR compatibility on forks 2026-05-02 01:26:47 +02:00
Luca ede5193e58 Update docker-build.yml 2026-05-02 01:26:47 +02:00
Luca ec2eaf76ea ci: build multi-arch images on every channel via native arm64 runners 2026-05-02 01:26:47 +02:00
Luca c282a72bd3 feat: support Apple Silicon natively via multi-arch images 2026-05-02 01:26:47 +02:00
Paul Nothaft ac040fbef8 Merge pull request #361 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.4-beta.0
2026-05-02 00:11:05 +02:00
github-actions[bot] 1fae9b6099 chore(beta): release 3.32.4-beta.0 2026-05-01 22:07:34 +00:00
Paul Nothaft af2b0628cb Merge pull request #360 from the-luap/fix/issue-hero-logo-position
fix(events): stop mapping branding_logo_position onto hero_logo_position
2026-05-02 00:07:24 +02:00
Paul Nothaft 07b41e691d Merge pull request #359 from the-luap/fix/issue-358-theme-flash
fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
2026-05-02 00:07:04 +02:00
Paul Nothaft ef1c875f6e fix(events): stop mapping branding_logo_position onto hero_logo_position
Two settings with overlapping names but different value sets were being
conflated:

- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position    (hero block, vertical):   'top'|'center'|'bottom'

getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.

Fix:

1. Drop the bogus mapping. branding_logo_position is no longer read by
   getBrandingDefaults — it doesn't belong there. The fallback default
   ('top') is used unless the request body explicitly provides
   hero_logo_position, which is independently validated.

2. Migration 084_fix_hero_logo_position normalises any existing rows
   whose hero_logo_position is outside ('top','center','bottom') back
   to 'top'. Without this, affected events would continue to 400 on
   every save until the admin manually picks a valid option.

Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
2026-05-02 00:04:06 +02:00
Paul Nothaft f81a8728e6 fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
Opening a gallery with a dark theme briefly painted a white background
between the initial HTML render and React applying the per-event theme.
The HTML shipped with no theme info, so the first paint used the
default (#fafafa) before /gallery/:slug/info resolved.

Two-part fix.

1. Inline bootstrap script in index.html runs synchronously before React
   mounts. Reads the URL, looks up a per-slug background colour from
   localStorage (gallery-theme-bg-<slug>), and applies it to
   documentElement immediately. Falls back to #171717 when no cache
   exists and the OS prefers dark, so first visits with dark OS still
   land on a dark background.

2. ThemeContext.applyTheme writes the resolved background to
   localStorage keyed by slug whenever a gallery theme loads. Revisits
   then hit the bootstrap cache and never see a flash.

Added a 200ms transition on html.background-color so the rare
cache→API drift (e.g. theme palette changed admin-side since last
visit) is a smooth fade instead of a snap.

Limitation: first visit on a light-OS device to a dark gallery still
flashes once. Killing that case requires a server-rendered theme hint,
out of scope for an SPA bootstrap fix.

The empty-skeleton-grid part of the same report is already addressed
by the 300ms lazy render in #352 — Rekoo-PS just needs to update from
v3.32.1-beta.0 to v3.32.2-beta.0+.
2026-05-01 23:53:55 +02:00
Paul Nothaft 9597333ddc Merge pull request #357 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.3-beta.0
2026-05-01 23:44:57 +02:00
github-actions[bot] 03dd99a8b1 chore(beta): release 3.32.3-beta.0 2026-05-01 21:29:08 +00:00
Paul Nothaft e5712d8ffe Merge pull request #356 from the-luap/fix/issue-create-event-expiry-date-coercion
fix(events): coerce expires_in_days to Number before addDays
2026-05-01 23:28:40 +02:00
Paul Nothaft 83dedbcd45 Merge pull request #355 from the-luap/fix/issue-350-jwt-verify-symmetry
fix(auth): /auth/session must verify issuer claim like adminAuth (#350)
2026-05-01 23:28:26 +02:00
Paul Nothaft db29d0e278 fix(events): coerce expires_in_days to Number before addDays
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:

  _date.setDate(_date.getDate() + amount)

When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.

Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.

The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
2026-05-01 23:22:56 +02:00
Paul Nothaft 88a6c6a7fb fix(auth): make /auth/session verify the issuer claim like adminAuth (#350)
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit 23cd9cb,
"address Shannon security assessment findings (37 vulnerabilities)").

The frontend uses GET /auth/session as the source of truth for "is the
user authenticated?". That endpoint called jwt.verify(token, JWT_SECRET)
with no issuer option, so it accepted pre-issuer tokens and reported
valid: true. AdminLoginPage then redirected to /admin/dashboard, every
protected endpoint went through adminAuth which DOES verify the issuer,
each one rejected the token with 401, the response interceptor
window.location.href'd back to /admin/login, and the loop closed.

Fix: pass { issuer: 'picpeak-auth' } to /auth/session's jwt.verify so it
matches adminAuth and galleryAuth. Tokens without the claim now correctly
return valid: false from the session check, AdminLoginPage shows the
login form, and a fresh login mints a properly-issued cookie.

The other intentionally-lax verify call sites (logout-flow logging,
photoAuth, sessionTimeout, rateLimit) are unrelated to the loop and stay
lax — their callers don't gate "authenticated?" decisions on the result.

Reproducer: open a removed/archived gallery URL with a stale admin
cookie from before the issuer claim was added, click "Back to home" on
the gallery-not-found page → loop.
2026-05-01 23:12:20 +02:00
Paul Nothaft 8f7258bfc8 Merge pull request #353 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.2-beta.0
2026-05-01 22:48:31 +02:00
github-actions[bot] 34fdddef51 chore(beta): release 3.32.2-beta.0 2026-05-01 20:37:32 +00:00
Paul Nothaft 6229b38bac Merge pull request #352 from the-luap/fix/issue-321-346-348-and-discussions
fix: events search/counters (#346), lazy gallery skeleton (#321), smooth lightbox swipe (#348)
2026-05-01 22:37:16 +02:00
Paul Nothaft 743086d3cb fix(lightbox): smooth carousel swipe + drop instructional hint (#348)
Two fixes for discussion #348.

Carousel-style swipe
The lightbox previously snapped to the next photo on swipe, then showed
a loading spinner while the new image fetched — choppy compared with
the reference video the reporter shared. The current photo is now
rendered inside a 3-slide track (prev/current/next). As the finger
drags, the track follows; on release the track animates to the
neighbouring slot or springs back if the gesture didn't pass the
threshold. Because the prev/next AuthenticatedImages render up front,
the browser starts fetching them while the user is still on the
current photo, so there's no loader flash on commit.

- Phase machine ('idle' | 'dragging' | 'committing' | 'springing')
  drives the track's transform/transition. Commit + spring use a 280ms
  cubic-bezier ease.
- Percentage-based transforms avoid measuring container width before
  the first paint. Commit threshold (read from the ref on demand) is
  max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least
  40px of movement).
- transitionend advances currentIndex with wrap-around and resets the
  track in one batch — slot contents rotate and the track snaps from
  the commit position back to centered with transition: none, so the
  visible image stays put. No flicker.
- Vertical-cancel (>24px dy) abandons the drag and springs back so the
  user keeps the gesture they intended.
- touch-action: none on the carousel container stops the browser
  fighting us with edge-swipe back navigation and native pinch-zoom.
- Pinch starting mid-drag springs the track back smoothly so the image
  doesn't jerk under the second finger.
- onTouchCancel covers system-interrupted gestures (incoming call etc).
- dragX === 0 short-circuits to 'idle' instead of 'springing' so taps
  don't get stuck waiting for a transitionend that never fires.
- Neighbour slides use a simplified AuthenticatedImage render (no
  canvas/fragment-grid pipeline) since they're only on screen during
  the swipe; the current slide keeps the full protection chain.
- Neighbour videos render their thumbnail rather than spinning up a
  VideoPlayer. When the *current* photo is a video, the carousel is
  bypassed entirely — single VideoPlayer + no swipe handlers — because
  sliding a video element during a drag is awkward and adds nothing.
- Removed the now-redundant imageLoaded state + spinner;
  AuthenticatedImage already shows a placeholder while loading.

Keyboard arrows and the on-screen Prev/Next buttons still snap (no
animation) — animating them would have required input queuing for
fast double-presses, and the request was specifically about swipe.

"Swipe to navigate" hint
Removed the mobile-only overlay text. Swipe is universal in image
viewers; the instruction read like training wheels and competed with
the photo for attention.
2026-05-01 20:55:42 +02:00
Paul Nothaft d9d81372b8 fix(gallery): lazy-render skeleton grid for fast loads (#321 follow-up)
The gallery loading skeleton now renders the header bars immediately but
delays the 12-tile placeholder grid by 300ms. Galleries that load
quickly (the common case) never flash the empty grid before the real
photos render — addressing the follow-up reported on #321 — while
slower loads still get a placeholder so the page doesn't sit blank.
2026-05-01 20:55:19 +02:00
Paul Nothaft a5b20ca3fe fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.

Backend
- adminEvents.js: extend search to include customer_email so the column
  shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
  page can render an accurate "All (N)" / Total Events counter without
  walking the full table on the client.

Frontend
- events.service.ts: getEvents() now accepts search + the full status
  enum (active|inactive|archived|draft|expiring); response type matches
  the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
  filter, and 300ms-debounced search; Prev/Next + range/page indicator
  below the table; placeholderData keeps the previous page visible
  during fetches; stat cards and "All (N)" pull from /dashboard/stats so
  totals stay accurate regardless of the visible page; archive/delete
  invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
  'expiring') directly instead of slicing the first 100 client-side. As
  a side effect the dashboard's "expiring" definition now matches the
  backend (was excluding events expiring within the next 24h).
2026-05-01 20:55:13 +02:00
Paul Nothaft 92847bc06b Merge pull request #345 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.1-beta.0
2026-04-30 09:09:37 +02:00
github-actions[bot] 7e65921ba6 chore(beta): release 3.32.1-beta.0 2026-04-30 07:08:45 +00:00
Paul Nothaft 02ed5d4007 Merge pull request #344 from the-luap/chore/move-docs-to-picpeak-app
docs: move documentation to docs.picpeak.app, drop in-repo copies
2026-04-30 09:08:16 +02:00
Paul Nothaft 0faf9b3281 docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built
from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the
v1 OpenAPI generation flow all point there now.

Removed (now living at docs.picpeak.app):
- DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment)
- docs/ADMIN_SETUP_GUIDE.md
- docs/JWT_SECRET_MIGRATION.md
- docs/SECURITY_BEST_PRACTICES.md
- docs/admin-api-quickstart.md → docs.picpeak.app/api
- docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy
- docs/openapi.json, docs/openapi.yaml → still generated locally as a
  build artifact (now gitignored), synced into picpeak-docs by
  scripts/sync-api-docs.sh
- docs/picpeak-admin-api.openapi.yaml → ditto

Kept:
- docs/*.png (logo + screenshots — README still img-tags these)

Updated:
- README.md — replaced six in-repo doc links with docs.picpeak.app
  pointers, restructured the Documentation section as a curated link
  list to the new site
- SIMPLE_SETUP.md — single deployment-guide link redirected
- .gitignore — docs/openapi.{json,yaml} are now build artifacts, not
  tracked
- backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow
2026-04-29 22:24:15 +02:00
Paul Nothaft 39af382eb2 Merge pull request #343 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.32.0-beta.0
2026-04-29 20:43:46 +02:00
github-actions[bot] 784c92fc4d chore(beta): release 3.32.0-beta.0 2026-04-29 18:35:09 +00:00
Paul Nothaft 7ea4801544 Merge pull request #342 from the-luap/feat/webhook-payload-enrichment
feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
2026-04-29 20:34:46 +02:00
Paul Nothaft 1e69d5ff71 feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.

Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:

  { id, slug, event_name, event_type, event_date,
    share_url, share_token,
    customer_name, customer_email, customer_phone }

Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.

Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)

PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
  warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
  null contract, and a Callout warning.

Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
2026-04-29 20:32:20 +02:00
Paul Nothaft 1b1d816009 Merge pull request #340 from the-luap/refactor/settings-nav-grouped
refactor(settings): grouped left-rail nav replaces overflowing tab bar
2026-04-29 00:03:04 +02:00
Paul Nothaft f171f6b974 refactor(settings): grouped left-rail nav replaces overflowing tab bar
The Settings page packed 13 tab buttons into a single horizontal nav
that overflowed even at 1440px — items wrapped or got clipped, and
"Webhooks" disappeared off the right edge entirely. Pattern was the
right call at 5 tabs and broken at 13.

Replaces the flat row with the macOS Settings / Stripe / GitHub pattern:

- **Desktop (lg+)**: 220px sticky left rail with five labelled groups —
  General, Display, Privacy & Security, Integrations, System — and a
  lucide icon next to every item. Active state uses the existing primary
  token. Adds a section header on the right pane that echoes the active
  item so the context is obvious after a switch.
- **Mobile (< lg)**: native <select> with <optgroup> per category. One
  tap to switch, no horizontal scroll, screen-reader friendly.

Categories chosen to be balanced (avg 2.6 items/group) and to map to
how admins actually think about these settings rather than alphabetical
or insertion order. Ports the existing inline-fallback i18n pattern for
the new group labels.
2026-04-28 23:59:54 +02:00
Paul Nothaft 625711af96 Merge pull request #339 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.1-beta.0
2026-04-28 22:15:23 +02:00
github-actions[bot] c5a2ec3842 chore(beta): release 3.31.1-beta.0 2026-04-28 16:06:38 +00:00
Paul Nothaft 1e4067713c Merge pull request #338 from the-luap/fix/post-329-bug-triage
fix: mobile lightbox + share previews + customer phone bug triage
2026-04-28 18:06:15 +02:00
Paul Nothaft 42a7ae4be8 fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336)
When feedback was enabled the lightbox bottom toolbar packed counter +
zoom + download + like + 5-star + comments into a single row that
overflowed the viewport on iPhone-class widths, putting the rating
stars under the screen edge and below the iOS home indicator.

Changes:
- Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile,
  so all controls fit (375px viewport: max-right 363 < 375; 390px:
  max-right 378 < 390; 393px: max-right 393 < 393).
- pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row
  sits above the iOS home indicator on devices with a gesture bar.
- Close button top/right now use max(1rem, env(safe-area-inset-*)) so
  it doesn't disappear under the notch / dynamic island.
- "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it
  clears the now-taller wrapped toolbar.
- index.html viewport meta gains viewport-fit=cover to enable
  env(safe-area-inset-*) on iOS Safari.

Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14
(390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape
(852x393) — toolbar fits, photo centered, no clipping.
2026-04-28 16:50:44 +02:00
Paul Nothaft fcddfe094b fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)
Found via real-browser verification: with useState the prior commit's
handleTouchEnd captures swipeStart from its render closure, so when
touchstart and touchend fire inside the same React batch (fast swipe,
synthetic events, or a tight render cycle) the end handler reads the
stale null and skips navigation. useRef sidesteps the closure entirely
and is the right primitive for cross-event scratchpad state anyway.

Verified in a 4-photo gallery on mobile-emulation (390x844 touch):
- left swipe (-200px) advances 1/4 → 2/4
- right swipe (+200px) returns 2/4 → 1/4
- 20px swipe (under threshold) does not navigate
- vertical swipe (dy 300, dx 20) does not navigate
2026-04-28 15:55:09 +02:00
Paul Nothaft 5275621fcd fix(share): OG/Twitter-card metadata for gallery share URLs (#333)
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for
shared gallery links — the SPA's stub index.html has no OG tags and the
meta-injection in DynamicFavicon happens at runtime, which crawlers
never see (they don't execute JS).

Add a backend OG handler at /og/gallery/:slug that returns minimal HTML
with proper og:* and twitter:* meta sourced from the event row + branding
settings (event name, formatted date, welcome_message excerpt as
description, configured logo as the preview image, FRONTEND_URL-based
canonical). Honours slug redirects so renamed galleries still get rich
previews.

Wire crawler detection in both nginx configs (production and dev) — UA
match against the standard list (facebookexternalhit, WhatsApp, Slackbot,
Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal
rewrite to /og/gallery/:slug, while humans fall through to the SPA via
try_files. The OG endpoint is also wired into the native-install SPA
fallback in server.js for setups that bypass nginx.

The OG image is intentionally the brand logo, not a gallery photo —
crawlers fetch it without auth, and password-protected gallery photos
must not leak via share previews.
2026-04-28 15:05:44 +02:00
Paul Nothaft 4c8eba0cb4 fix(gallery): single-finger swipe nav in mobile lightbox (#332)
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch
handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe
fell through and the user could only navigate with the on-screen arrows.

Add a 1-finger swipe detector: track the initial touch position, and on
touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious /
goToNext when the horizontal swipe exceeds 50px, dominates over
vertical motion (1.2x), and completes within 600ms. Suppressed while
zoomed in so the user can pan the image instead.
2026-04-28 15:05:30 +02:00
Paul Nothaft 4c73d228ed fix(events): show customer phone in event details view (#331)
The phone field added in #322 was wired into the edit form but never
rendered in the read-only event-info panel, so admins could only see the
number while editing. Add a phone row gated on event_phone_field_enabled
(same toggle the form uses), and tighten the Event type so customer_phone
is no longer accessed via `(event as any)`.
2026-04-28 15:05:22 +02:00
Paul Nothaft ca8acacd43 Merge pull request #335 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.31.0-beta.0
2026-04-28 14:53:06 +02:00
github-actions[bot] f58b52a9d1 chore(beta): release 3.31.0-beta.0 2026-04-28 12:51:00 +00:00
Paul Nothaft 06d54bec4d Merge pull request #334 from the-luap/feat/post-319-fixes-and-features
feat: S3 storage + webhooks + settings dedupe + backup fixes
2026-04-28 14:50:35 +02:00
Paul Nothaft e232f9f2cf fix(backup): incremental backups against S3 + jsonb stats parsing
Three fixes uncovered while bringing the backup-s3 integration suite to
12/12 against MinIO + Postgres:

- backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses
  `statistics` / `table_checksums` to objects; the old JSON.parse() then
  threw "[object Object]" is not valid JSON and the manifest dropped
  database info silently. Accept both string and object inputs.

- backupService.runBackup: incremental path called
  backupManifest.loadManifest() with an s3:// URI directly, which falls
  through to fs.readFile() and ENOENTs — every "incremental" backup
  silently downgraded to a full one. Added loadManifestFromAnywhere()
  helper that downloads s3:// to a tmp file before delegating.

- backupManifest.generateIncrementalManifest: attached the `incremental`
  section AFTER generateManifest() had already stamped
  verification.total_checksum, so every incremental manifest failed
  validateManifest() on read-back. Recompute the checksum after.

Test side: updated assertions to the current manifest shape
(`incremental.changes.modified_files_count`), Number()-coerce bigint
columns from pg, and gate the logger mock on UNMOCK_LOGGER for
diagnosing similar silent-failure modes in the future.
2026-04-28 10:52:49 +02:00
Paul Nothaft ab4095f592 fix(backup): cron schedule mapping + manifest format detection + bigint coerce
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.

1. Backup service crashes on backend startup with
   `TypeError: Cannot read properties of undefined (reading 'replace')`
   from node-cron's expression parser.

   Root cause: `backup_schedule` stores a UI label like "weekly", while
   `backup_schedule_cron` stores the actual cron expression. Startup
   code read the label and passed it straight to cron.schedule() —
   "weekly" is not a cron expression.

   Fix in startBackupService(): read backup_schedule_cron first; fall
   back to mapping known labels (hourly/daily/weekly/monthly) to cron
   expressions; back-compat for deployments that wrote a cron expression
   into the legacy backup_schedule field.

2. Backup manifest retrieval fails with
   `SyntaxError: Unexpected token 'a', "applicatio"...` when the
   manifest format is YAML.

   Root cause: getBackupManifest() downloads the s3:// manifest to a
   tmp file hardcoded as `manifest-N.json`. loadManifest() then
   detects format from extension only — sees .json, runs JSON.parse on
   YAML content (which starts with "application: …"), fails.

   Fix in backupManifest.loadManifest(): detect format from BOTH the
   extension AND the content's first non-whitespace character. JSON
   starts with { or [; anything else falls through to yaml.load.
   Backwards compatible — extension is still authoritative when present
   AND content matches.

3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
   fails with "received value must be a number or bigint" because pg
   driver returns bigint columns as strings. Coerce via Number() in
   the test.

Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
  upload the database backup file at S3 key `database/db-backup.sql`.
  Current implementation reads db backup metadata for the manifest but
  does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
  modified_files_count. Implementation writes backupType: 'incremental'
  on the run row but no per-run incremental subobject in the manifest.
  Field shape mismatch.
2026-04-28 10:15:46 +02:00
Paul Nothaft 446d80a4cc feat: presigned download UI + S3 prefix walker auto-importer (follow-ups)
Closes the user-facing surface for the two #328 follow-ups previously
landed in code form (presigned route + S3 mode notes), plus the schema
migration that backs both #328 and #327 follow-ups.

Migration 083
- events.allow_presigned_download — per-event opt-in for the
  presigned-URL "Download All" path. Off by default because it bypasses
  watermarks; admins flip it knowingly. Mutually exclusive with
  watermark_downloads.
- webhooks.filter (jsonb default {}) — dot-path equality predicate
  evaluated at fire time. Empty object = no filter, fire always.
  Backs the filter logic that shipped with #327.
- webhooks.template (text nullable) — optional ${dot.path} string
  substitution applied at delivery time. NULL = use the default JSON
  envelope (back-compat). Backs the template logic from #327.

S3 prefix walker (services/s3AutoImporter.js)
- Replaces the chokidar file-watcher in S3 mode (where there's no
  inotify equivalent on remote objects).
- Polls every active event's S3 prefix every 5 min by default
  (STORAGE_AUTO_IMPORT_INTERVAL_MS overridable).
- Eventual-consistency gate: an object is only imported after it's
  been seen for two consecutive polls. Avoids flapping when S3 returns
  a freshly-uploaded object that disappears on the next list (a
  documented S3 behavior on certain backends).
- Skips generated artifacts (thumb_*, hero_*, dot-files).
- Inserts photos rows + fires photo.uploaded webhooks the same way
  the local fileWatcher does.
- Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds
  API call cost.

EventDetailsPage UI (frontend)
- Round D queryKey alignment for #325 dedup — replaces useQuery on
  publicSettingsService with the shared usePublicSettings() hook so
  the page joins the same React Query cache as every other consumer.
- Per-event "Allow direct S3 download (no watermark, S3 mode only)"
  toggle in Download Protection. Disabled when watermark_downloads is
  on; tooltip explains the bandwidth/watermark trade-off. Toggling
  watermark_downloads on automatically clears allow_presigned_download
  to keep the two mutually exclusive in the UI.

Verified live against MinIO
- Presigned: GET /api/gallery/.../download-all → 302 with
  Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300.
  Following the URL inside the docker network → HTTP 200, valid
  PK ZIP archive containing the photo.
- Auto-importer: dropped a file via `mc cp` directly into the bucket;
  watcher imported it after 2 polls; webhook subscribed to
  photo.uploaded fired with source=s3-auto-import; receiver got POST
  with valid HMAC, status=success, 3ms latency.
2026-04-28 10:08:21 +02:00
Paul Nothaft c488f481ca feat: outbound webhooks for event/photo lifecycle (#327)
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each
delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header.
Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration
tests, full UI click-through via Chrome DevTools.

Schema (migration 082)
- webhooks: id, name, url, secret (plaintext — required to compute HMAC
  for every outbound POST), secret_preview, events[], active, filter,
  template, created_by, timestamps, last_success_at/last_failure_at.
- webhook_deliveries: webhook_id (FK CASCADE), event_type, payload,
  attempt_count, status (pending|success|failed), response_status,
  response_body (truncated to 1KB), latency_ms, next_retry_at,
  last_error, created_at, completed_at. Composite index
  (status, next_retry_at) serves the worker's hot-path query.

Service + worker
- webhookService.fire(eventType, data) — non-throwing entry point used
  by lifecycle hooks. Looks up active webhooks subscribed to the event
  and applies their per-webhook filter (dot-path equality predicate)
  before enqueueing one webhook_deliveries row per match. Filter and
  template logic ship in this commit; admin surfaces in the follow-up.
- webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5
  pending rows; per delivery: re-validates URL via networkValidation
  (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS),
  signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome.
  Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response
  body truncated to 1KB before storage. If a webhook has a template,
  the rendered string replaces the JSON envelope as the request body
  (signature is computed over the bytes actually sent).

Lifecycle wiring
- adminEvents.js POST /events → event.created (+ event.published when
  not draft); POST /:id/publish → event.published.
- routes/events.js (legacy public POST) → event.created + event.published.
- routes/v1/events.js (#322 API) → event.created + event.published on
  create, photo.uploaded on photo POST.
- archiveService.archiveEvent() → event.archived. Per-photo
  photo.deleted intentionally NOT fired during cascade — receivers
  infer from event.archived to avoid flooding (issue spec).
- expirationChecker.handleExpiredEvent() → event.expired BEFORE the
  cascading archive (so receivers see expired→archived in order).
- adminPhotos.js — photo.uploaded on each batch row, photo.deleted on
  single + bulk delete.
- photoProcessor.js — photo.uploaded for guest uploads + auto-import
  (covers all entry paths).
- fileWatcher.js — photo.uploaded on add, photo.deleted on unlink
  (local mode only).

Admin endpoints (mirrors adminApiTokens.js pattern)
- /api/admin/webhooks: GET list, POST create (returns plaintext secret
  exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic
  fire), GET :id/deliveries (paginated, filter by status), GET
  :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay.

Frontend
- Settings → Webhooks tab (mirrors API Tokens layout): name + URL +
  event checkboxes + "Advanced" expander for filter (JSON) and template.
  Plaintext secret shown once on creation with a Copy button. Active/
  Disabled toggle button per row.
- /admin/webhooks/:id/deliveries — operational debug surface. Table
  with timestamp/event/status/attempts/HTTP/latency. Status filter chips
  (all/pending/success/failed). Row click → slide-over with payload +
  signature + response body. Replay button on failed rows. Send-test-event
  dialog. Auto-refresh every 10s.

Dev infrastructure
- dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that
  records every POST to an in-memory ring buffer. Exposes GET /requests
  for the E2E spec to assert deliveries landed with the right HMAC.
  Sibling pattern to MinIO. Reachable from the backend at
  http://webhook-receiver:8888 inside the picpeak network.

Tests
- backend/__tests__/integration/webhookDelivery.test.js (8/8) —
  signature verification, headers, retry/backoff, max-attempts → failed,
  response truncation, disabled-mid-flight, SSRF block, start/stop
  idempotency.
- tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger
  event.published → assert receiver got POST with valid HMAC → visit
  deliveries page → row visible with status=success → API test event →
  API replay → disable webhook → assert no new delivery.

Docs
- README §"Webhooks" — event catalog, payload shape, HMAC verification
  in Node + Python + bash, retry semantics, SSRF protection.
- .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS,
  WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS,
  WEBHOOK_MAX_ATTEMPTS.

Out of scope for v1 (per issue): webhook templates' code-eval (the
${dot.path} substitution that ships is pure string replacement, no
expression engine — see follow-up commit), per-webhook rate limiting
beyond the global concurrency cap, synchronous "ask before delete"
webhooks.

Spanning files
- App.tsx pulls in this commit with both the AnalyticsBootstrap
  (#325 dedup) and the WebhookDeliveriesPage route registration.
  Splitting via git add -p was forfeit for sanity; the single 92-line
  diff is honest about both contributions.
- adminEvents.js diff bundles the webhook fires AND the
  allow_presigned_download field plumbing (#328 follow-up). Same
  reasoning.
- The new webhookService/Worker/adminWebhooks files include the filter
  and template logic from the follow-up — they were authored in one
  pass; splitting them post-hoc would have produced fragile partial
  files. The follow-up commit covers the migration and the UI for these.
2026-04-28 10:07:39 +02:00
Paul Nothaft 1b717ce5ed feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and
archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2,
Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local
filesystem. Selected via STORAGE_BACKEND=local|s3.

Architecture
- backend/src/services/storage/StorageBackend.js — abstract interface
  (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/
  getToFile) — typedef-only, documents the contract.
- LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path
  traversal protection, list-as-walker.
- S3StorageBackend.js — thin wrapper around the existing
  S3StorageAdapter (used by backupService) mapping it onto the canonical
  interface; supports optional STORAGE_S3_PREFIX namespace.
- index.js — factory selected by STORAGE_BACKEND with startup ping
  (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast
  before the first request.

Consumer refactors (~12 services + routes), each parametrized over the
abstraction:
- imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through
  storage.put; expose withLocalCopy() helper for S3-mode regeneration
  paths that need a local file for sharp/ffmpeg.
- archiveService / downloadZipService — finalize zip in tmp dir, then
  storage.putFromFile. Atomic-rename pattern preserved on local; S3
  emulates via copy + delete (worker prunes orphaned .tmp.* on startup).
- photoProcessor / photoReplacementService / adminPhotos upload+delete /
  routes/v1/events.js POST /events/:id/photos / routes/events.js — every
  upload path now goes storage.putFromFile(temp) → unlink temp.
- gallery.js bulk-download (cached + on-the-fly + selected) — managed
  photos via storage.get, external-mode unchanged.
- protectedImages / secureImages / photoResolver — read via
  storage.get; resolvePhotoStorageKey returns the canonical key.
- watermarkService / watermarkGeneratorService — persistent watermarks
  via storage.put.
- fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3
  (chokidar can't watch S3); auto-import lands via the S3 prefix walker
  introduced in the follow-up commit.
- expirationChecker — small touch (event.expired webhook fire from #327
  shipping in the next commit).

Migration tooling
- backend/scripts/migrate-storage.js — one-shot --dry-run capable script
  that walks photos.path, thumbnail_path, hero_path, watermark_path and
  events.archive_path/download_zip_path; streams local → S3; sha256
  size-match skip for idempotent re-run; failures CSV.

Presigned-URL "Download All" (#328 follow-up shipped in this commit)
- routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download
  + downloads enabled + watermark NOT enabled, /download-all returns a
  302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface
  ships in the next commit's UI.

Tests
- backend/__tests__/integration/storageBackend.test.js — parametrized
  contract suite running against BOTH LocalFs AND MinIO (18 tests, both
  backends — 36 cases total).
- backend/__tests__/integration/imageProcessor.storage.test.js — same
  parametrized pattern for the image processor (10 tests × 2 backends).
- backend/__tests__/integration/backup-s3.test.js — bootstrap fix:
  drop the redundant initDb() (001_init handles it) and remove
  schema-drift in configureS3Backup (app_settings has no created_at
  anymore and the unique constraint is on setting_key alone, not
  composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift).
- backend/src/services/photoResolver.js — mixed-source events (reference
  mode with managed-uploaded photos) now fall back to managed when
  external_relpath is missing instead of throwing.
- tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that
  auto-skips against local backend; full upload → serve → delete
  round-trip when run against an S3-mode backend.

Server wiring (server.js)
- initStorage() called after database init, before rate limiters.
- This commit's diff also includes the webhook delivery worker startup
  and the S3 auto-importer startup. Those features ship in the next two
  commits — co-located here for one bisectable diff per file.

Docs + ops
- README §"Storage Backends" — capability matrix, switching playbook,
  IAM policy snippet, MinIO/R2/B2 examples.
- README §"Webhooks" — also added here (full diff bundled).
- .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT
  documented; WEBHOOK_* added in the same diff.
- .gitignore — re-anchor the existing `storage/` rule to `/storage/`
  so backend/src/services/storage/ (the new abstraction code) is
  trackable. The runtime ./storage/ data dir stays ignored.

Out of scope for v1 (per the issue): presigned URLs for individual
photo display (always streamed for protection middleware), CDN
integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket
per-event.
2026-04-28 10:06:36 +02:00
Paul Nothaft 3d4ae4d7e9 feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
2026-04-28 10:01:53 +02:00
Paul Nothaft 2794ed6722 Merge pull request #330 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.30.0-beta.0
2026-04-27 23:53:57 +02:00
github-actions[bot] ca0e48eb68 chore(beta): release 3.30.0-beta.0 2026-04-27 20:41:31 +00:00
Paul Nothaft 11de7b65c9 Merge pull request #329 from the-luap/feat/post-319-fixes-and-features
Bugfixes, public API, CMS error pages, BMC sponsor link
2026-04-27 22:41:02 +02:00
Paul Nothaft 46bc894d91 docs: add Buy Me a Coffee badge + Support section
Adds a yellow Buy Me a Coffee badge to the header alongside the existing
License/Docker/Node/React badges, plus a small "Support the Project"
section above Acknowledgments with the standard BMC button image. Also
adds a link in the inline nav row at the top of the README so first-time
visitors can find it.

Link: https://buymeacoffee.com/theluap

Lightweight, opt-in support — explicitly notes that starring, sharing,
filing good bug reports, and opening PRs are equally welcome ways to
help if money isn't in the budget.
2026-04-27 22:38:00 +02:00
Paul Nothaft 038e84cae7 fix: dedupe parallel admin 401 redirects to /admin/login
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.

The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.

Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.

Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
2026-04-27 22:38:00 +02:00
Paul Nothaft 2eead52319 fix: theme picker buttons no longer submit the parent form (#326)
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.

When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").

When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.

Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
2026-04-27 22:38:00 +02:00
Paul Nothaft 808b15bafb feat: public v1 API + token management + OpenAPI docs (#322)
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.

API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
  last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
  resolves to the owner admin user, attaches `req.admin` so existing
  permission decorators (events.create etc.) still work. Token-level
  scope check (read/write/admin) layers on top as defence in depth —
  a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
  authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
  POST /events/:id/photos (multipart, single file), GET
  /events/:id/share-link. Each endpoint annotated with @openapi JSDoc.

Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
  /api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
  to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
  copies it into the picpeak-docs Nextra site at app/api/. Writes only,
  never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).

Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
  tokens are shown once with a copy-to-clipboard control.
2026-04-27 22:38:00 +02:00
Paul Nothaft be6cb28c80 feat: optional customer phone field gated by global toggle (#322)
Adds a `customer_phone` column on events plus an `event_phone_field_enabled`
admin setting (default off) that surfaces the input in the create-event
and event-detail forms. Designed for downstream automation tooling — once
exposed via the upcoming public API, n8n / similar can pick it up to
deliver gallery links over WhatsApp, SMS, etc.

- Migration 080 adds the column + seeds the setting as false. Existing
  deployments see no UI change unless the admin opts in via
  Settings → Events.
- Backend strips the field server-side when the toggle is off (defence
  in depth against form bypass).
- Frontend renders the input only when the public-settings flag is true;
  always optional even then.
- publicSettings + EventSettings types extended; CreateEventPage and
  EventDetailsPage wired to read the toggle and submit the value.
2026-04-27 22:38:00 +02:00
Paul Nothaft 4f77905b87 feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.

Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
  with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
  migration on existing deployments. Null falls back to the global
  branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
  clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
  storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.

Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
  the standard branded shell (logo precedence: page → branding → bundled
  default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
  identifier + infoError archived/missing) into a single
  CMSContentBlock("gallery-not-found"), so admins can edit one source
  of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
  control per page; falls back to the page's own English title in the
  page list when no `legal.<slug>` translation is registered.
2026-04-27 22:38:00 +02:00
Paul Nothaft b63a8774c4 fix: theme-preset match loop ignores extra fields like logoUrl (#323)
The "which preset does this saved theme match?" loop in BrandingPage and
CreateEventPage was doing a full JSON.stringify equality on preset.config
vs the loaded theme. The previous #323 logo-preservation work means the
saved theme legitimately carries a `logoUrl` (and any other fields the
parent maintains), so the equality check would never match and the
preset summary fell back to "Custom Theme" / Classic Grid even when the
saved theme was structurally Dark Modern, etc.

Compare only on the preset's own keys instead. Surfaced by the new
smoke spec 07-branding-default-on-create-event which would otherwise
pass green against the broken state.
2026-04-27 22:38:00 +02:00
Paul Nothaft 793e410554 fix: floor password_changed_at when comparing against JWT iat
JWT `iat` has 1-second resolution; `password_changed_at` is stored with
sub-second precision. The previous comparison rejected tokens whose iat
fell in the same wall-clock second as a password change — e.g. a token
issued by an immediate re-login after a password reset, or by any
script-driven flow that resets and logs in in quick succession. Floor
the stored timestamp to whole seconds before comparing.

Caught while wiring up the local E2E suite: the seeder needed a
"set password_changed_at 10 s in the past" hack to avoid this race;
with the fix in place that hack is gone and the suite is naturally
deterministic.
2026-04-27 22:38:00 +02:00
Paul Nothaft 8d0fb8e157 chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors
(and the local E2E watchdog) can detect a silent process restart between
two checks — e.g. an unhandled rejection that crashes Node and Docker
quietly relaunches the container.

Also adds .gitignore patterns for a local-only E2E suite that lives in
tests/e2e/local/ on individual machines and is never pushed.
2026-04-27 22:38:00 +02:00
Paul Nothaft 822be9a9b2 fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)
#323-A — Branding colour changes weren't persisting unless "Apply changes
immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was
gating its `onChange` callback on `isPreviewMode`, but the parent
BrandingPage already gates global `setTheme()` on its own copy of that
flag — so the customizer's gate was double-gating and silently dropped
the new values from the parent state that Save reads from. Always
propagate `onChange`; let parents decide what's "live". Removed the now
no-op `isPreviewMode` prop and dropped the unused passers.

#323-B — Default theme set in Branding wasn't applied to new events.
CreateEventPage only inherited the event-type's recommended preset, with
'default' falling back to Classic Grid. Now reads `settings.theme_config`
on first load and uses it as the form's starting theme; the event-type
effect skips the generic 'default' so the Branding default sticks for
event types like "Other".

#321 — Visitors saw four sequential render states when opening a gallery
(full-page "Loading Gallery" → "publicly accessible — loading photos"
card → skeleton grid → real gallery). Extracted the skeleton into a
shared <GallerySkeleton/> and used it for both GalleryView's photos-
loading state and GalleryPage's gallery-info-loading + public-auto-login
phases. The "publicly accessible" interstitial is gone. Net: one
continuous skeleton from URL open until real photos render.
2026-04-27 22:38:00 +02:00
Paul Nothaft 63a6bfebce Merge pull request #320 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.1-beta.0
2026-04-26 22:52:44 +02:00
github-actions[bot] 3d5759738f chore(beta): release 3.29.1-beta.0 2026-04-26 20:52:07 +00:00
Paul Nothaft 2f2f405d9b Merge pull request #319 from the-luap/feat/prezip-and-photo-replace
fix: discussion #317 issues and #318 archive crash
2026-04-26 22:51:46 +02:00
Paul Nothaft 6cfff6f6a6 fix: address bugs and feature requests from discussion #317
- Share link: display and copy now use the absolute URL built from the
  current origin instead of the relative path stored in events.share_link.
  Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
  enable_devtools_protection app setting instead of always falling back to
  the column default; admins who disable it globally get new events with
  it disabled too.
- Require password default: added a global "Require password by default"
  setting (event_default_require_password, default true), exposed via
  Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
  sort row in the public gallery when off, or when the gallery has zero
  photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
  so its identity is stable. The "auto-apply event-type recommended
  preset" effect was firing on every render due to the unstable array
  reference and silently overwriting the user's preset selection ~1ms
  after each click.
- Branding logo disappearing on theme change: handlePresetChange and
  handleThemeChange no longer wipe the existing logoUrl when a preset
  config (which carries no logoUrl) is applied; handleSave falls back to
  brandingSettings.logo_url. themeMutation now invalidates the
  admin-settings and public-settings caches so saved theme changes appear
  immediately.
2026-04-26 22:48:59 +02:00
Paul Nothaft e4b0f961b7 fix: prevent backend crash on archive when admin_email is null (#318)
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.

- Skip queueEmail when event.admin_email is null/empty (admin_email has
  been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
  instead of crashing the process.
2026-04-26 22:15:00 +02:00
Paul Nothaft c0989796e4 Merge pull request #315 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.29.0-beta.0
2026-04-23 16:59:00 +02:00
github-actions[bot] 82adcd1f71 chore(beta): release 3.29.0-beta.0 2026-04-23 14:54:21 +00:00
Paul Nothaft d3f1206816 Merge pull request #314 from the-luap/feat/prezip-and-photo-replace
feat: pre-zip download all and photo replacement by name (#312, #313)
2026-04-23 16:49:59 +02:00
Paul Nothaft e18afd3e6b feat: pre-zip download all and photo replacement by name (#312, #313)
Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking

Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
2026-04-23 16:49:31 +02:00
Paul Nothaft 4353acebf9 Merge pull request #311 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.3-beta.0
2026-04-13 13:01:08 +02:00
github-actions[bot] 89f86b9fe4 chore(beta): release 3.28.3-beta.0 2026-04-13 05:30:51 +00:00
Paul Nothaft ceb2a09f48 Merge pull request #310 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: revert /api prefix in adminPhotos.js to avoid double-prefix (#307)
2026-04-13 07:30:28 +02:00
Paul Nothaft 094276d3cc fix: revert /api prefix in adminPhotos.js to avoid double-prefix
AdminPhotoGrid uses AdminAuthenticatedImage which fetches via Axios
(baseURL: /api), so the backend URL must not include /api — Axios
adds it. The adminGuests.js /api prefix is correct because its
consumer (AuthenticatedImage) uses fetch() with buildResourceUrl().
2026-04-13 07:30:08 +02:00
Paul Nothaft 59b56ed3d7 Merge pull request #309 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.2-beta.0
2026-04-12 21:01:11 +02:00
github-actions[bot] 0a5b07de5d chore(beta): release 3.28.2-beta.0 2026-04-12 18:58:40 +00:00
Paul Nothaft b05c36ac81 Merge pull request #308 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
2026-04-12 20:58:24 +02:00
Paul Nothaft 9323befdd9 fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
- Render welcome_message in gallery view for all non-fullpage layouts
  (grid, masonry, carousel, timeline, mosaic) as a centered banner
- Add /api prefix to thumbnail/photo URLs in adminGuests.js and
  adminPhotos.js so they route correctly through Nginx proxy
2026-04-12 20:58:02 +02:00
Paul Nothaft 61142c0d0e Merge pull request #305 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.1-beta.0
2026-04-12 10:06:43 +02:00
github-actions[bot] 623ab72916 chore(beta): release 3.28.1-beta.0 2026-04-12 08:06:08 +00:00
Paul Nothaft 3716ff5085 Merge pull request #304 from the-luap/fix/gallery-sort-direction-and-feedback-visibility
fix: apply sort direction in gallery and respect show_feedback_to_guests (#302, #303)
2026-04-12 10:05:52 +02:00
Paul Nothaft dffe057772 fix: apply sort direction in gallery view and respect show_feedback_to_guests (#302, #303)
- Gallery now respects the configured sort direction (asc/desc) from
  default_photo_sort setting instead of using hard-coded directions
- Photos endpoint zeroes out feedback fields (like_count, favorite_count,
  average_rating, comment_count, has_feedback) when show_feedback_to_guests
  is disabled, while still showing data to admin/client users
2026-04-12 10:05:24 +02:00
Paul Nothaft 3319a304ce Merge pull request #301 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.28.0-beta.0
2026-04-11 23:24:59 +02:00
github-actions[bot] c303dd51e8 chore(beta): release 3.28.0-beta.0 2026-04-11 21:24:41 +00:00
Paul Nothaft b1dfbe4c2f Merge pull request #300 from the-luap/feat/cookie-secure-auto
feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
2026-04-11 23:24:24 +02:00
Paul Nothaft 54badefc51 Merge pull request #299 from the-luap/fix/guest-masonry-lightbox
fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
2026-04-11 23:24:02 +02:00
Paul Nothaft 15a8ab41fd feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
Adds a third value for the COOKIE_SECURE environment variable that
decides the cookie Secure flag per-request based on req.secure. This
unblocks a common self-hosted setup where the same PicPeak deployment
is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g.
LAN access at http://192.168.x.x:3001).

Behavior

  unset  - legacy default: follows NODE_ENV (production=true, dev=false)
  true   - always set Secure (unchanged)
  false  - never set Secure (unchanged)
  auto   - NEW: use req.secure per request. In practice this means
           Secure on HTTPS requests (when X-Forwarded-Proto: https
           reaches Express via a trusted proxy) and no Secure flag
           on plain HTTP requests.

The existing trust proxy config (`app.set('trust proxy',
'loopback, linklocal, uniquelocal')` in server.js) means
X-Forwarded-Proto is honored when forwarded from local/private-network
proxies, which covers Docker network setups and most self-hosted
deployments behind NPM, Traefik, or Caddy.

auto is strictly opt-in. The default behavior is unchanged, so existing
users see no difference. A follow-up release can consider promoting
auto to the default after real-world feedback.

Also fixed (latent bug, benefits everyone)

Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies)
previously wrote the same `secure` attribute as the set path. When a
cookie was set with Secure=true over HTTPS and the clear request came
over HTTP (or vice versa under auto mode), some browsers would reject
the Set-Cookie delete header, leaving the cookie in place. Browsers
match cookies by (name, domain, path) for deletion and don't care about
Secure, so the new buildClearCookieOptions() helper simply omits the
secure attribute.

Implementation

- secureCookie string is replaced by secureCookieMode which can hold
  true, false, or 'auto'.
- New resolveSecureFlag(res) returns the boolean for a specific
  response, delegating to res.req.secure when in auto mode.
- buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res
  and pass it through.
- New buildClearCookieOptions() deliberately omits `secure`.
- setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie /
  clearGalleryAuthCookies all updated to thread res where needed.
  Public signatures unchanged — every caller already has res in scope.

Testing

Verified against a real Express instance inside the backend container
with trust proxy configured, covering:

  - (unset) + NODE_ENV=production -> secure: true (legacy)
  - (unset) + NODE_ENV=development -> secure: false (legacy)
  - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins)
  - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins)
  - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true
  - COOKIE_SECURE=auto + plain HTTP -> secure: false
  - clearCookie always omits the secure attribute

Documentation

Added a COOKIE_SECURE block to both .env.example files (root for
docker-compose, backend/.env.example for native install) explaining the
four values, when to use auto, and the two requirements (proxy must
forward X-Forwarded-Proto, proxy IP must be in the trust list). Also
documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were
previously undocumented.
2026-04-11 22:41:15 +02:00
Paul Nothaft 77f07e9329 fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:

1. Masonry grid showed no visual feedback after liking a photo.
   MasonryGalleryLayout's Like button had no liked-state plumbing —
   the Heart icon was a static <Heart> regardless of whether the user
   had liked the photo.

2. PhotoLightbox (fullscreen view) silently failed to like photos in
   guest identity mode. submitLike() and submitRating() never called
   ensureIdentity() before firing the API request, so the first
   interaction from a fresh session hit a 401 from the server instead
   of opening the name prompt.

Root causes:

1. MasonryGalleryLayout was missing the 'liked' state pattern that
   GridGalleryLayout already uses (likedPhotoIds Set in the parent,
   passed down as a `liked` prop, updated via onLikeSuccess callback).
   The bug was invisible in simple mode (no personal state) but
   surfaced immediately in guest mode where each guest expects to see
   confirmation of their own action.

2. PhotoLightbox's submit handlers were written before the guest
   identity context existed and only checked the legacy
   require_name_email flag. They were never updated when guest mode
   landed.

Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].

Changes:

- MasonryGalleryLayout.tsx
  - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
  - Like button: red bg + filled white Heart icon when liked; aria-label
    toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
  - onClick wires onLikeSuccess() for optimistic UI in both guest-mode
    and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
  - Parent layout holds `likedPhotoIds: Set<number>` and passes it to
    each MasonryPhoto (matches the GridGalleryLayout pattern).

- PhotoLightbox.tsx
  - Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
  - submitLike() and submitRating() get a guest-mode branch that calls
    ensureIdentity() first and submits without body guest_name/email
    (server reads from the verified token).
  - Optimistic UI updates happen after successful submit in guest mode.

- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
  - z-50 → z-[60] so they render above PhotoLightbox.

Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):

- Fresh session, click Like in Masonry grid → name prompt opens, register,
  feedback persists with guest_id, Heart button turns red with
  aria-pressed and "Unlike photo" label. Subsequent likes on other
  photos also show red state. DB confirms feedback rows.

- Fresh session, open photo in lightbox BEFORE registering → click Like,
  the name prompt correctly opens on top of the lightbox, register,
  feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
  in lightbox, ★ badge appears on toggle-feedback button, grid cell
  shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.

- Backend DB: gallery_guests row created, photo_feedback rows have
  correct guest_id, server reads name from verified token (body values
  ignored).

Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
2026-04-11 14:42:28 +02:00
Paul Nothaft 72c0c2d18e Merge pull request #297 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.27.0-beta.0
2026-04-11 09:27:58 +02:00
github-actions[bot] 95d8bc4065 chore(beta): release 3.27.0-beta.0 2026-04-11 06:29:51 +00:00
Paul Nothaft 3856ba25bb Merge pull request #295 from the-luap/feat/guest-selections
feat: guest selections with per-person identity (#292)
2026-04-11 08:27:31 +02:00
Paul Nothaft 9e1ba4f851 Merge pull request #296 from the-luap/release-please--branches--beta
chore(beta): release 3.26.2-beta.0
2026-04-11 08:27:08 +02:00
github-actions[bot] b0efd32f7a chore(beta): release 3.26.2-beta.0 2026-04-11 06:26:39 +00:00
Paul Nothaft 9ed8a2b199 Merge pull request #294 from the-luap/fix/admin-photo-feedback-filters
fix: admin photo feedback filters have no effect (#293)
2026-04-11 08:26:19 +02:00
Paul Nothaft ad4e5a7506 feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
2026-04-11 07:48:23 +02:00
Paul Nothaft d4b4dc628f fix: wire admin photo feedback filters into grid query (#293)
The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.

Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
  accept has_likes, has_favorites, has_comments, min_rating, and logic
  (AND/OR) query params and apply them via where-clause groups using
  the existing denormalized like_count/favorite_count/comment_count/
  average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
  hasComments, minRating, logic to the PhotoFilters interface and
  append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
  into combinedPhotoFilters (via useMemo) and key the admin-event-photos
  query on it, so toggling any checkbox refetches with the new params.

Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
2026-04-11 07:46:32 +02:00
Paul Nothaft fe46e4268d Merge pull request #288 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.1-beta.0
2026-04-09 16:40:53 +02:00
github-actions[bot] 1f3b9c6712 chore(beta): release 3.26.1-beta.0 2026-04-09 14:27:12 +00:00
Paul Nothaft 5295516b67 Merge pull request #290 from the-luap/docs/fix-filesystem-gallery-docs
docs: clarify file system photo import requires existing event (#269)
2026-04-09 16:26:51 +02:00
Paul Nothaft ee0baafc59 docs: clarify file system photo import requires existing event (#269)
The "Method 2: File System" section in SIMPLE_SETUP.md implied you
could create a gallery by just copying files to the storage directory.
In reality, the event must exist in the database first — the file
watcher only adds photos to existing events.

Rewritten to clarify the prerequisite and explain how the file watcher
works (2s stability delay, supported formats, auto-thumbnailing).
2026-04-09 16:26:39 +02:00
Paul Nothaft c63bc47089 Merge pull request #289 from the-luap/fix/password-change-regular-modal
fix: apply password change fix to regular modal + longer toast delay (#263)
2026-04-09 16:06:13 +02:00
Paul Nothaft 147dc28440 fix: apply password change redirect fix to regular modal too (#263)
The redirect loop fix only covered MandatoryPasswordChangeModal.
The regular PasswordChangeModal (profile settings) had the same
issue — onSuccess updated React state but didn't handle the new
JWT cookie, causing the same redirect loop.

Also increase redirect delay from 500ms to 2000ms in both modals
so the success toast is visible before the page reloads.
2026-04-09 16:05:53 +02:00
Paul Nothaft c031b1e863 Merge pull request #287 from the-luap/fix/password-change-iat-timing
fix: resolve JWT iat timing issue in password change (#263)
2026-04-09 16:00:25 +02:00
Paul Nothaft b1d16670d5 fix: set JWT iat after password_changed_at to prevent token rejection (#263)
The new token issued after password change had iat (integer seconds)
that was <= password_changed_at (millisecond precision), causing the
auth middleware's "iat < passwordChangedTime" check to reject it
immediately. Set iat explicitly to 1 second after password_changed_at.

E2E tested: login → mandatory password change → dashboard loads
successfully with no redirect loop and no 401 errors.
2026-04-09 16:00:03 +02:00
Paul Nothaft ba1f010166 Merge pull request #285 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.26.0-beta.0
2026-04-09 15:40:18 +02:00
github-actions[bot] ad64005a80 chore(beta): release 3.26.0-beta.0 2026-04-09 13:11:41 +00:00
Paul Nothaft 8805fa53e6 Merge pull request #286 from the-luap/feat/photo-sort-by-capture-date
feat: sort photos by capture date with configurable default sort (#283)
2026-04-09 15:11:19 +02:00
Paul Nothaft 633d4a0f30 feat: sort photos by capture date with configurable default sort (#283)
Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)

Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend

Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
  Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
  "date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date

i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.

Closes #283
2026-04-09 15:10:51 +02:00
Paul Nothaft b23c51b386 Merge pull request #284 from the-luap/fix/password-change-loop-and-filewatcher
fix: resolve password change redirect loop (#263) and file watcher crash (#269)
2026-04-09 13:54:54 +02:00
Paul Nothaft 835bdf5abb fix: resolve password change redirect loop and file watcher crash
#263: The mandatory password change modal updated React state before
the browser stored the new JWT cookie, causing a race condition where
the auth context checked the session with the old (invalidated) token.
Replace the state update with a full page redirect to /admin/dashboard
after a brief delay, ensuring the new cookie is applied cleanly.

#269: The file watcher service imported isVideoMimeType from
fileSecurityUtils where it doesn't exist. The function is exported
from videoProcessor. Fix the import path.

Closes #269
2026-04-09 13:54:23 +02:00
Paul Nothaft a1b63de251 Merge pull request #279 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.25.0-beta.0
2026-04-08 12:03:29 +02:00
github-actions[bot] 97b1ae5b03 chore(beta): release 3.25.0-beta.0 2026-04-08 09:51:27 +00:00
Paul Nothaft dc98206737 Merge pull request #278 from the-luap/feat/draft-mode-branding-improvements
feat: draft mode, admin branding, and workflow improvements
2026-04-08 11:51:07 +02:00
Paul Nothaft 40332a71db feat: draft mode, admin branding, and workflow improvements
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
2026-04-08 11:42:38 +02:00
Paul Nothaft 125cd0d003 Merge pull request #274 from the-luap/security/fix-dep-vulnerabilities
security: fix 20 dependency vulnerabilities
2026-04-08 09:04:19 +02:00
Paul Nothaft 83868ffe2f security: fix 20 dependency vulnerabilities (11 error, 7 warning, 2 note)
Update direct dependencies and overrides to address GitHub code scanning alerts:

- handlebars 4.7.8 -> 4.7.9 (5 CVEs: RCE, DoS, XSS, code execution)
- nodemailer 7.0.12 -> 7.0.13 (SMTP command injection)
- tar 7.5.11 -> 7.5.13 override (symlink/hardlink path traversal)
- fast-xml-parser >=5.3.8 -> >=5.5.10 override (entity expansion bypass)
- brace-expansion >=5.0.0 -> >=5.0.5 override (DoS via zero step)
- path-to-regexp 0.1.12 -> 0.1.13 override (ReDoS via malformed URL params)
- lodash 4.17.23 -> >=4.18.1 override (prototype pollution, code execution)

The picomatch CVEs are in npm's own node_modules inside the Docker image
and do not affect application code.
2026-04-08 09:04:06 +02:00
Paul Nothaft 9ddd50f7e4 Merge pull request #266 from the-luap/security/pin-axios-version
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:40:47 +02:00
Paul Nothaft bec36fc99f security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper (plain-crypto-js) attributed to North Korean threat
actor UNC1069/Sapphire Sleet. The malicious versions have been removed
from npm but our ^1.12.2 range could have pulled 1.14.1 on next install.

Pin to exact version 1.14.0 (latest safe release) in both frontend and
backend package.json and lock files to prevent any future resolution to
compromised versions.

References:
- https://github.com/axios/axios/issues/10604
- https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/
2026-04-05 18:40:24 +02:00
Paul Nothaft ea50488e99 Merge pull request #265 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.1-beta.0
2026-04-05 18:36:13 +02:00
github-actions[bot] 8614c2232c chore(beta): release 3.24.1-beta.0 2026-04-05 16:34:26 +00:00
Paul Nothaft 07fc5e6519 Merge pull request #264 from the-luap/fix/password-change-redirect-loop
fix: resolve redirect loop after mandatory password change (#263)
2026-04-05 18:34:07 +02:00
Paul Nothaft 3c8d344ddd fix: resolve redirect loop after mandatory password change (#263)
After changing password, the backend sets password_changed_at which
invalidates the old JWT token. But the frontend still holds the old
token in the HttpOnly cookie, so the next session check returns 401,
triggering an infinite redirect loop between /admin/login and
/admin/dashboard.

Fix: issue a new JWT token cookie after successful password change
so the session remains valid without requiring re-login.
2026-04-05 18:33:46 +02:00
Paul Nothaft edf8bd54af Merge pull request #262 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.24.0-beta.0
2026-04-05 18:24:36 +02:00
github-actions[bot] 2b7c9b0138 chore(beta): release 3.24.0-beta.0 2026-04-04 21:33:29 +00:00
Paul Nothaft aef9b4ed7f Merge pull request #261 from the-luap/feat/beta-theme-thumbnail-warning
feat: warn about low thumbnail resolution with beta themes
2026-04-04 23:33:12 +02:00
Paul Nothaft ee3f6ae13b feat: warn about low thumbnail resolution when selecting beta themes
Beta themes (Gallery Premium, Gallery Story) display thumbnails at
400-800px, but the default thumbnail size is 300x300px, causing visible
pixelation. Show an amber warning banner with a link to Thumbnail
Settings when a beta layout is active and thumbnails are below 500px.

Warning appears both in the preset selector and the layout selector
sections of the theme customizer.
2026-04-04 23:32:49 +02:00
Paul Nothaft 5025a42bf7 Merge pull request #259 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.23.0-beta.0
2026-04-04 22:28:44 +02:00
github-actions[bot] 0a7a89045b chore(beta): release 3.23.0-beta.0 2026-04-04 20:13:49 +00:00
Paul Nothaft ddefd3a95e Merge pull request #260 from the-luap/fix/backend-dockerfile-npm-version
fix: pin npm to v10 in backend Dockerfile
2026-04-04 22:13:32 +02:00
Paul Nothaft 978e4473b5 fix: pin npm upgrade to v10 in backend Dockerfile
npm@latest resolves to v11 which has a broken promise-retry dependency
on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which
stays compatible with the Node 22 base image.
2026-04-04 22:13:11 +02:00
Paul Nothaft 8c5996e4ec Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
2026-04-04 17:43:29 +02:00
Paul Nothaft f50d7c0c51 feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with
a normalized email_template_translations table where each language is a row.
This allows adding new languages without schema changes.

- Add migration 075 to create email_template_translations table, migrate
  existing EN/DE data, and seed NL/PT/RU for customer-facing templates
- Update processTemplate() to query translations table with fallback chain
  (requested lang -> en -> first available), with legacy column fallback
- Restructure admin email API to return/accept translations object format
- Update frontend EmailConfigPage with dynamic 5-language tabs, translation
  count badges, and copy-from-language feature for empty translations
- Add Dutch to default language dropdown in general settings
- Add Dutch to clientAccessI18n and password security messages in emails
- Expand email domain detection for NL/BE/BR/PT/RU domains
- Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
2026-04-04 17:43:01 +02:00
Paul Nothaft 4ce8dd297a Merge pull request #257 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.22.0-beta.0
2026-03-26 08:04:59 +01:00
github-actions[bot] 85a4eb90fd chore(beta): release 3.22.0-beta.0 2026-03-25 21:36:31 +00:00
Paul Nothaft e32da68cbd Merge pull request #256 from the-luap/feat/add-dutch-locale
feat: add Dutch locale and fix missing translation keys
2026-03-25 22:36:15 +01:00
Paul Nothaft b54a80d251 feat: add Dutch (nl) locale and fix missing translation keys across all locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the
language selector. Also synchronize all existing locales so every language
has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing
keys to DE (moderation, analytics, CSS templates, backup, events).
2026-03-25 22:35:57 +01:00
github-actions[bot] 2ac6c51fe5 chore(beta): release 3.21.1-beta.0 (#255)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-22 12:43:55 +01:00
Paul Nothaft 23cd9cb680 fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-22 12:40:01 +01:00
github-actions[bot] a63f1a8dd9 chore(beta): release 3.21.0-beta.0 (#253)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 10:59:14 +01:00
Paul Nothaft 954a0118ba fix: wrap test email with standard email template (#252)
Use wrapEmailHtml() for the test email so it matches the look of all
other emails sent by the platform (logo, footer, etc.).

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:49:45 +01:00
Paul Nothaft ee46088985 feat: add per-gallery thumbnail scale setting (#172) (#251)
Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings
that adjusts column counts for Grid, Masonry (columns mode), and Mosaic
layouts. Each scale maps to a column offset applied on top of the
layout's base columns, letting photographers control photo density.

- Add thumbnailScale to GalleryLayoutSettings type
- Apply scale offset in Grid, Masonry, and Mosaic layout components
- Add thumbnail scale dropdown to admin theme customizer
- Conditionally show dropdown only for applicable layouts
- Safelist dynamic grid-cols classes in Tailwind config
- Add i18n keys for EN, DE, PT, RU locales

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:44:07 +01:00
github-actions[bot] 3742d71535 chore(beta): release 3.20.1-beta.0 (#250)
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-17 17:18:56 +01:00
Paul Nothaft 486239aeb9 fix: address beta feedback - gallery layout fixes, Russian locale, email logo (#249)
- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-17 17:16:50 +01:00
Paul Nothaft 2c5ae6fbb9 Merge pull request #248 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.20.0-beta.0
2026-03-17 13:14:43 +01:00
github-actions[bot] f9889a93fb chore(beta): release 3.20.0-beta.0 2026-03-17 12:05:29 +00:00
Paul Nothaft 4a93e4e8cb Merge pull request #247 from the-luap/feat/photo-visibility-client-access
feat: photo visibility control with client access (#172)
2026-03-17 13:05:12 +01:00
Paul Nothaft e1b6e43e52 feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to
review and hide photos before the gallery is shared with guests.

Backend:
- Migration 074: add visibility column to photos, client_access_enabled/
  client_password_hash/client_share_token to events
- Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN
- Gallery photo list filters hidden photos for guests, shows all for clients
- Visibility toggle endpoints (single + bulk) for client access level
- Admin event CRUD supports client access fields
- Email template includes client access link + PIN (EN/DE/RU/PT)

Frontend:
- ClientAccessPage: PIN entry form at /gallery/:slug/client-access
- GalleryView: client mode banner, visibility counter, toggle controls
- GridGalleryLayout: eye/eye-off overlay per photo for clients
- AdminPhotoGrid: visibility badge, bulk Hide/Show buttons
- EventDetailsPage: Client Access settings section (toggle, PIN, link)
- CreateEventPage: client access toggle + PIN in event creation form
- GalleryAuthContext: accessLevel/isClient/clientLogin support
- New complete pt-BR locale (pt.json) with all translations
- Client access i18n keys for EN, DE, RU, PT
2026-03-17 13:04:41 +01:00
Paul Nothaft 999c66dbbf Merge pull request #244 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.2-beta.0
2026-03-16 22:37:45 +01:00
github-actions[bot] f5997892c4 chore(beta): release 3.19.2-beta.0 2026-03-16 21:35:09 +00:00
Paul Nothaft 7ca96315e2 Merge pull request #243 from the-luap/fix/security-session-invalidation
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:34:53 +01:00
Paul Nothaft f3622396e7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:34:32 +01:00
Paul Nothaft 56cf60c570 Merge pull request #242 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.1-beta.0
2026-03-16 22:32:17 +01:00
github-actions[bot] 2618415aa1 chore(beta): release 3.19.1-beta.0 2026-03-16 21:23:14 +00:00
Paul Nothaft dfae2c2bc6 Merge pull request #241 from the-luap/fix/external-media-dimensions-and-email-colors
fix: external media dimensions, theme race condition, email color customization
2026-03-16 22:22:58 +01:00
Paul Nothaft bbeedd1888 fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
2026-03-16 22:22:36 +01:00
Paul Nothaft 201965b4b1 Merge pull request #240 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.19.0-beta.0
2026-03-16 20:25:20 +01:00
github-actions[bot] 1468c459ba chore(beta): release 3.19.0-beta.0 2026-03-16 16:24:01 +00:00
Paul Nothaft 088de43f09 Merge pull request #239 from the-luap/feat/photo-cap-and-portuguese-locale
feat: add photo cap per event and Portuguese locale
2026-03-16 17:23:37 +01:00
Paul Nothaft 1fa222e9c4 feat: add photo cap per event and Portuguese (pt-BR) locale
- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
2026-03-16 17:22:54 +01:00
Paul Nothaft 6aceb40595 Merge pull request #238 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.2-beta.0
2026-03-16 16:26:55 +01:00
github-actions[bot] 431a82eca1 chore(beta): release 3.18.2-beta.0 2026-03-16 15:26:11 +00:00
Paul Nothaft 85a07fcca7 Merge pull request #237 from the-luap/fix/security-dep-updates
fix: resolve code scanning security alerts (multer, tar, Node 22)
2026-03-16 16:25:52 +01:00
Paul Nothaft 1f524f2358 fix: update dependencies to resolve code scanning security alerts
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests)
- Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786)
- Upgrade Node base image from 20-alpine to 22-alpine to fix npm
  bundled tar/minimatch CVEs in the Docker image
2026-03-16 16:25:29 +01:00
Paul Nothaft 48a025b915 Merge pull request #236 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.1-beta.0
2026-03-16 15:02:04 +01:00
github-actions[bot] c652ae0ead chore(beta): release 3.18.1-beta.0 2026-03-16 14:01:13 +00:00
Paul Nothaft 9a6d2e8e3a Merge pull request #235 from the-luap/fix/email-preview-wrapper
fix: wrap email preview with full styled header/footer template
2026-03-16 15:00:56 +01:00
Paul Nothaft fc0911acf8 fix: wrap email preview with full styled header/footer template
The email template preview modal was showing only raw body HTML without
the styled wrapper (green header bar, logo, footer with company name)
that processTemplate() applies when sending. This made preview not match
what recipients actually receive.

Extract wrapEmailHtml() from processTemplate() and reuse it in the
preview endpoint. Also fix logo URL to use FRONTEND_URL consistently.

Closes #229
2026-03-16 15:00:37 +01:00
Paul Nothaft f77802325a Merge pull request #234 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.18.0-beta.0
2026-03-16 10:22:14 +01:00
github-actions[bot] 74c9a5fbcd chore(beta): release 3.18.0-beta.0 2026-03-16 08:38:17 +00:00
Paul Nothaft 703c03fbee Merge pull request #233 from the-luap/feat/visual-email-editor
feat: visual WYSIWYG email template editor
2026-03-16 09:37:57 +01:00
Paul Nothaft 6f95b8c26c feat: register Russian locale and add to language selector
Import ru.json translations in i18n config and add Russian with flag
to the language selector dropdown.
2026-03-16 09:35:03 +01:00
Paul Nothaft 7250c427b9 fix: shorten Save button label on email template editor
Change "Save Changes" to "Save" for cleaner toolbar layout.
2026-03-16 09:29:24 +01:00
Paul Nothaft 04a7ea80f9 feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
2026-03-15 22:05:27 +01:00
Paul Nothaft c0a5cd56c8 Merge pull request #232 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:12:22 +01:00
Paul Nothaft 908ab08815 Merge beta to resolve conflicts for PR #232 2026-03-15 20:02:27 +01:00
Paul Nothaft 52ab609597 i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions
that were added after the initial Russian localization PR (#216).
2026-03-15 19:48:41 +01:00
Paul Nothaft fafcfbf4e6 Merge pull request #216 from Ih0rd/russian-localization
basic Russian localization
2026-03-15 19:47:25 +01:00
Paul Nothaft f07602553c Merge pull request #228 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.2-beta.0
2026-03-11 21:54:58 +01:00
Paul Nothaft 56f497c5f1 Merge pull request #227 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m29s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.1
2026-03-11 21:54:39 +01:00
github-actions[bot] d2663bff81 chore(beta): release 3.17.2-beta.0 2026-03-11 19:48:06 +00:00
github-actions[bot] b52cf1f741 chore(main): release 2.6.1 2026-03-11 19:48:05 +00:00
Paul Nothaft 308e086263 Merge pull request #226 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:51 +01:00
Paul Nothaft 7f7736282f Merge pull request #225 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:41 +01:00
Paul Nothaft 67b0f32456 fix: update security policy with proper contact email and private reporting
- Replace placeholder security@example.com with info@picpeak.app
- Add GitHub Private Vulnerability Reporting links
- Update supported versions table to 2.x.x

Closes #223
2026-03-11 20:21:32 +01:00
Paul Nothaft 25b40c03b0 Merge pull request #224 from the-luap/release/beta-to-main
Merge beta into main
2026-03-11 20:19:10 +01:00
Paul Nothaft 28793bba68 Merge main into beta for release/beta-to-main 2026-03-11 20:12:52 +01:00
Paul Nothaft 4ae91142f8 Merge pull request #222 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / build-frontend (push) Failing after 3m18s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.0
2026-03-11 12:01:51 +01:00
github-actions[bot] c92879fbd3 chore(main): release 2.6.0 2026-03-11 10:57:06 +00:00
Paul Nothaft a0bb080586 Merge pull request #221 from the-luap/fix/video-upload-select-all-dimensions
fix: video upload, select all, and dimension repair (#203, #220, #180)
2026-03-11 11:56:38 +01:00
Paul Nothaft fc75bcdfc3 fix: video upload media type, select all, and dimension repair (#203, #220, #180)
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
2026-03-11 11:50:43 +01:00
Paul Nothaft 9877f63aed Merge pull request #219 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.1-beta.0
2026-03-08 15:47:06 +01:00
github-actions[bot] 0c98c6b453 chore(beta): release 3.17.1-beta.0 2026-03-08 14:42:28 +00:00
Paul Nothaft 831ea6a3bc Merge pull request #218 from the-luap/fix/optional-email-event-creation
fix: respect optional email settings in event creation
2026-03-08 15:42:14 +01:00
Paul Nothaft 9c44a0ebfa fix: respect optional email settings in event creation (#217)
When admin/customer emails were configured as optional in Settings >
Event Creation, the backend still rejected empty values because:

1. express-validator .optional() only skips undefined, not empty strings
   — changed to .optional({ values: 'falsy' }) so "" is treated as
   absent
2. DB columns host_email and admin_email had NOT NULL constraints
   — added migration to make them nullable
3. Email queue insert crashed on null recipient_email
   — skip queuing when no customer email is provided
2026-03-08 15:36:38 +01:00
Ih0rd a840ad4594 basic Russian localization 2026-03-06 06:17:30 +03:00
Paul Nothaft 08ac238d0a Merge pull request #215 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.17.0-beta.0
2026-03-05 22:22:58 +01:00
github-actions[bot] 7d967a47ae chore(beta): release 3.17.0-beta.0 2026-03-05 21:21:48 +00:00
Paul Nothaft 9b7495e005 Merge pull request #214 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:21:29 +01:00
Paul Nothaft e1ad4219a5 Merge pull request #212 from the-luap/revert-210-feat/configurable-upload-batch-size
Revert "feat: configurable upload batch size for reverse proxy compatibility"
2026-03-05 22:16:43 +01:00
Paul Nothaft cc4503ad28 Revert "feat: configurable upload batch size for reverse proxy compatibility" 2026-03-05 22:16:28 +01:00
Paul Nothaft 424336340b Merge pull request #210 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:14:41 +01:00
Paul Nothaft a8308a5c02 Merge pull request #209 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.16.0-beta.0
2026-03-05 22:14:28 +01:00
Paul Nothaft 02a46e083d feat: add configurable upload batch size for reverse proxy compatibility (#208)
Users behind Cloudflare Tunnel and other reverse proxies cannot upload
batches >100MB. The upload chunking previously used a hardcoded 500MB
limit. This adds a configurable `max_upload_batch_size_mb` setting
(default 95MB) to the admin General settings, leaving headroom below
Cloudflare's 100MB limit.
2026-03-05 22:12:37 +01:00
github-actions[bot] 98fd6dd8e1 chore(beta): release 3.16.0-beta.0 2026-03-05 20:38:56 +00:00
Paul Nothaft 3a30fea862 Merge pull request #207 from the-luap/fix/github-issues-194-197-main
feat: add thumbnail settings UI to admin panel
2026-03-05 21:38:41 +01:00
Paul Nothaft 7d6d2f5688 feat: add thumbnail settings UI to admin settings page (#206)
Add a new "Thumbnails" tab in the admin settings page allowing users to
configure thumbnail dimensions, quality, format, and fit mode from the UI.
Also fix backend route column name mismatch (key/value → setting_key/setting_value)
that caused a 500 error, and add a button to regenerate all thumbnails.
2026-03-04 22:55:14 +01:00
Paul Nothaft b5074e4e46 Merge pull request #205 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.3-beta.0
2026-03-02 23:18:27 +01:00
github-actions[bot] a1d941f049 chore(beta): release 3.15.3-beta.0 2026-03-02 22:18:06 +00:00
Paul Nothaft 80171713e0 Merge pull request #204 from the-luap/fix/github-issues-194-197-main
fix: issue #203 file type validation + security CVE fixes
2026-03-02 23:17:50 +01:00
Paul Nothaft c0301dcbf4 Merge branch 'beta' into fix/github-issues-194-197-main 2026-03-02 23:15:37 +01:00
Paul Nothaft cbecb9323c fix(security): resolve Docker image CVEs for code scanning alerts
- Upgrade nginx base from 1.27-alpine to 1.28-alpine (Alpine 3.23, OpenSSL 3.5.5)
- Upgrade npm to latest in backend production stage to fix tar, minimatch, brace-expansion CVEs
- Add brace-expansion and minimatch overrides for app-level transitive deps
- Remove incompatible body-parser v2 override (breaks Express 4 JSON parsing)
- Remove npm upgrade from builder stages (npm 11 breaks npm ci with existing lockfile)
2026-03-02 23:06:15 +01:00
Paul Nothaft 4272618b3f fix(security): resolve all npm audit vulnerabilities
Frontend (6 → 0 vulnerabilities):
- axios: update to fix DoS via __proto__ key in mergeConfig (CVE-2026-25639)
- swiper: update to fix prototype pollution (critical)
- rollup: update to fix arbitrary file write via path traversal
- minimatch: update to fix multiple ReDoS vulnerabilities
- ajv: update to fix ReDoS with $data option
- markdown-it: update to fix ReDoS

Backend (32 → 0 vulnerabilities):
- multer: update to fix DoS via incomplete cleanup and resource exhaustion
- minimatch: update to fix multiple ReDoS vulnerabilities
- Add npm overrides for transitive dependencies:
  - fast-xml-parser >=5.3.8 (fixes XSS, DoS, stack overflow via AWS SDK)
  - qs >=6.14.2 (fixes arrayLimit bypass DoS via Express)
  - tar >=7.5.8 (fixes path traversal and hardlink attacks via sqlite3)

Docker:
- Pin nginx base image to 1.27-alpine in Dockerfile.prod
- Update security comments in backend Dockerfile
- Existing apk upgrade --no-cache ensures OpenSSL/libexpat CVEs are
  patched at build time (OpenSSL 3.5.5, Alpine 3.23.3)
2026-03-02 10:36:47 +01:00
Paul Nothaft fe07a148f1 fix: respect allowed_file_types setting for upload validation (#203)
The "Allowed File Types" admin setting was stored in the database but
never actually read during upload validation. Both frontend and backend
used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be
rejected even when explicitly added to the setting.

Changes:
- Add getAllowedMimeTypes() to uploadSettings service that reads the
  general_allowed_file_types DB setting and converts extensions to MIME types
- Backend admin upload route now resolves allowed types from settings
  before multer processes files (via resolveAllowedTypes middleware)
- Backend gallery upload route uses dynamic allowed types from settings
- Expose allowed_file_types in public settings API for gallery clients
- Frontend PhotoUpload and UserPhotoUpload components now derive allowed
  MIME types from settings instead of hardcoded image-only lists
- Add shared fileTypes.ts utility for extension-to-MIME conversion

Closes #203
2026-03-01 14:36:34 +01:00
Paul Nothaft 0ec4190e2e Merge pull request #201 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m17s
Build and Push Docker Images / build-frontend (push) Failing after 3m17s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.5.1
2026-02-23 20:10:21 +01:00
Paul Nothaft 0ec3787150 Merge pull request #200 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.2-beta.0
2026-02-23 20:10:12 +01:00
github-actions[bot] 59faf73f04 chore(main): release 2.5.1 2026-02-22 21:37:40 +00:00
github-actions[bot] 3e0c4fd73e chore(beta): release 3.15.2-beta.0 2026-02-22 21:37:26 +00:00
Paul Nothaft 33af088560 Merge pull request #199 from the-luap/fix/github-issues-194-197-main
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:21 +01:00
Paul Nothaft 5ea4ef3cf3 Merge pull request #198 from the-luap/fix/github-issues-194-197
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:11 +01:00
Paul Nothaft 33483cf32d fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:34:44 +01:00
Paul Nothaft cd00bc13d4 fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:27:21 +01:00
Paul Nothaft 26ec9666b9 Merge pull request #193 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m47s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.5.0
2026-02-21 20:52:30 +01:00
github-actions[bot] f672c1daa6 chore(main): release 2.5.0 2026-02-21 19:48:43 +00:00
Paul Nothaft 5f1f0f253d Merge pull request #192 from the-luap/release/beta-to-main
Release v3.15.1: Merge beta to main
2026-02-21 20:48:01 +01:00
Paul Nothaft 888c4ab209 Merge main into beta for release/beta-to-main
Resolved conflicts in CHANGELOG.md, backend/package.json, and
frontend/package.json. Version set to 3.15.1.
2026-02-21 20:43:46 +01:00
Paul Nothaft 551d9cc66f Merge pull request #191 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.1-beta.0
2026-02-21 20:32:31 +01:00
github-actions[bot] 9045402c9a chore(beta): release 3.15.1-beta.0 2026-02-21 19:31:37 +00:00
Paul Nothaft 0817443e79 Merge pull request #190 from the-luap/feat/new-features
fix: docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example (#189)
2026-02-21 20:31:20 +01:00
Paul Nothaft a4c624802b fix: update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example (#189)
- Replace deprecated docker-compose (v1) with docker compose (v2) in README
- Add missing ADMIN_PASSWORD to .env.example so new users don't get a
  blank-string warning and can actually log in after first setup
2026-02-21 08:28:49 +01:00
Paul Nothaft 79cf4100a1 Merge pull request #188 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.0-beta.0
2026-02-17 20:48:38 +01:00
github-actions[bot] fe9486e5fa chore(beta): release 3.15.0-beta.0 2026-02-17 19:47:41 +00:00
Paul Nothaft bcf2745ab6 Merge pull request #187 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, security hardening, and bug fixes
2026-02-17 20:47:25 +01:00
Paul Nothaft c4f16eb76c fix: events without expiration date incorrectly shown as expired
When expires_at is null (no expiration), the status logic defaulted
days to 0, causing all non-expiring events to display as "Expired".
Now returns "Active" immediately when there is no expiration date.
2026-02-17 15:48:57 +01:00
Paul Nothaft 5925ea8406 Merge pull request #186 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.14.0-beta.0
2026-02-17 15:38:34 +01:00
github-actions[bot] 6613f1b088 chore(beta): release 3.14.0-beta.0 2026-02-17 14:38:14 +00:00
Paul Nothaft 3ea9d5b121 Merge pull request #185 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, and security hardening
2026-02-17 15:37:56 +01:00
Paul Nothaft 0891be197f feat: show original filename in admin UI (#184)
Surface the existing original_filename from the database in the admin
photo grid hover overlay and photo viewer sidebar, so photographers can
correlate uploaded images with their Lightroom/disk originals. Only shown
when it differs from the system-generated filename. Gallery guests remain
unaffected.
2026-02-17 15:30:12 +01:00
Paul Nothaft 2b25d81144 security: comprehensive hardening across frontend, backend, and infrastructure
- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
  services to use safe spawn-based helpers
2026-02-16 22:33:20 +01:00
Paul Nothaft 50c09904a9 feat: add update instructions dialog, email notifications, and capture date sorting
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
2026-02-16 16:23:57 +01:00
Paul Nothaft 7aa37b2447 Merge pull request #183 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.1-beta.0
2026-02-15 22:53:50 +01:00
github-actions[bot] d239857d9a chore(beta): release 3.13.1-beta.0 2026-02-15 21:49:34 +00:00
Paul Nothaft 3974ba5de5 Merge pull request #182 from the-luap/feat/new-features
fix: restore aspect-ratio layouts and improve hero image quality (#180)
2026-02-15 22:49:20 +01:00
Paul Nothaft 5cef7fdd18 fix: restore aspect-ratio layouts and improve hero image quality (#180)
- Fix masonry/mosaic layout regression where tiles displayed uniform heights
  instead of respecting image aspect ratios. Changed from fixed 150-500px
  height constraints to dynamic constraints based on column width.

- Add hero image optimization pipeline generating 1920x1080 images for
  full-width hero sections instead of using low-quality thumbnails.

- New /hero/:photoId endpoint serves optimized hero images with watermark
  support and automatic generation/caching.

- Add hero_url field to photos API response for frontend consumption.

- Migration 069 adds hero_path column to photos table.
2026-02-15 22:43:18 +01:00
Paul Nothaft 092f007ed3 Merge pull request #179 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.13.0-beta.0
2026-02-07 00:36:35 +01:00
github-actions[bot] edf3a43950 chore(beta): release 3.13.0-beta.0 2026-02-06 23:35:55 +00:00
Paul Nothaft 45d78c0dce Merge pull request #178 from the-luap/feat/new-features
feat: improve hero image UX and live preview (#163, #158)
2026-02-07 00:35:37 +01:00
Paul Nothaft d63f67a2af feat: improve hero image UX and live preview (#163, #158)
- Update hero photo help text to mention category override capability
- Add hint in category manager about default hero photo fallback
- Add placeholder text in gallery preview for hero section
- Ensure live preview updates correctly for header/divider style changes
2026-02-07 00:29:25 +01:00
Paul Nothaft ad00eae251 Merge pull request #177 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.12.0-beta.0
2026-02-06 23:31:17 +01:00
github-actions[bot] 2c35543e73 chore(beta): release 3.12.0-beta.0 2026-02-06 22:29:57 +00:00
Paul Nothaft 7c75736719 Merge pull request #176 from the-luap/feat/new-features
Feat/new features
2026-02-06 23:29:39 +01:00
Paul Nothaft 9c2a0d272a feat: add admin dark mode and SEO/robots.txt settings
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
2026-02-06 23:26:01 +01:00
Paul Nothaft 4912e2bccf fix: improve ghost button visibility in admin dark mode
Update ghost button variant to use proper dark mode colors:
- Add dark:hover:bg-neutral-700 for hover state
- Add dark:text-neutral-300 for better icon/text visibility
- Fixes too-dark edit and view gallery buttons in Events table
2026-02-06 23:23:34 +01:00
Paul Nothaft f8c8abd70b fix: resolve mixed light/dark mode styling in admin UI (#175)
- Update .card class to use explicit Tailwind colors instead of CSS
  variables, preventing gallery theme from affecting admin UI
- Add .card-themed and .input-themed classes for gallery components
  that need to use theme CSS variables
- Add dark mode support to CardHeader and CardFooter components
- Update .input class to use explicit colors for proper light/dark mode
- Update dark mode selectors for consistency (.dark .class)
2026-02-06 23:13:15 +01:00
Paul Nothaft 7726adeff0 Merge pull request #174 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.11.0-beta.0
2026-02-06 21:46:42 +01:00
github-actions[bot] e05fd64760 chore(beta): release 3.11.0-beta.0 2026-02-06 20:46:15 +00:00
Paul Nothaft 4280444d70 Merge pull request #173 from the-luap/feat/new-features
feat: gallery layouts, hero customization, event types, and UX improvements (#146, #155-163, #170, #171)
2026-02-06 21:45:59 +01:00
Paul Nothaft 6491184402 chore: add dependencies for Gallery Premium/Story layouts
Add missing npm packages required for new gallery layouts:
- yet-another-react-lightbox: lightbox component
- framer-motion: animations
- photoswipe: photo gallery
- swiper: carousel/slider
2026-02-06 21:43:09 +01:00
Paul Nothaft 171abb3161 fix: improve password validation errors and event list UX (#170, #171)
- Show specific failing password requirement instead of generic error
  when password validation fails on AcceptInvitePage (#170)
- Add inline Edit and View Gallery buttons to events table (#171)
- Make event table rows clickable to navigate to details (#171)
- Keep context menu for less common actions (Archive, Delete)
- Add responsive design: inline buttons hidden on mobile
2026-02-06 18:38:12 +01:00
Paul Nothaft e179def3cc feat: add Gallery Premium and Gallery Story layouts (Beta)
- Add Gallery Premium layout: elegant light theme with masonry grid,
  hero section, sticky navigation, and integrated lightbox
- Add Gallery Story layout: cinematic dark theme with scene-based
  sections, carousels, and gold accents
- Implement full-page layout support: bypass standard header/footer/
  sidebar for immersive experience
- Add logout button to both layouts for authenticated galleries
- Mark both layouts as (Beta) in theme editor and layout selectors
- Fix hero title color visibility in Gallery Premium layout
2026-02-06 18:03:47 +01:00
Paul Nothaft bc6c48bb24 fix: render minimal/none header styles, cap hero height, switch category hero images (#158, #162, #163)
- Add distinct rendering branches for minimal and none header styles in
  GalleryLayout (grid and non-grid), skipping the colored banner/wave
  divider for both
- Cap hero section height at 700px via max-h to prevent it dominating
  ultra-wide viewports
- Watch selectedCategoryId in GalleryView and swap the hero photo to
  the category's hero_photo_id when filtering, reverting to the event
  default when cleared
- Add minimal/none preview branches in GalleryPreview so the admin
  theme editor shows visually distinct previews for all four styles
- Remove unused AdminPhoto import that was blocking the build
- Add Playwright e2e tests covering all four header styles, hero max
  height, and category hero switching
2026-02-04 08:30:55 +01:00
Paul Nothaft 57845a5508 Merge pull request #167 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 4m15s
Build and Push Docker Images / summary (push) Waiting to run
2026-02-03 19:09:07 +01:00
github-actions[bot] 10ff6b118c chore(beta): release 3.10.1-beta.0 2026-02-03 16:25:51 +00:00
Paul Nothaft 2288309395 Merge pull request #166 from the-luap/feat/new-features
fix: sync header_style DB column with theme editor selections (#158)
2026-02-03 17:16:09 +01:00
Paul Nothaft a19e7c40a2 fix: sync header_style DB column with theme editor selections (#158)
The frontend never sent header_style/hero_divider_style as separate
fields when creating or updating events, so the database columns always
kept their default value of 'standard' — making the hero header
impossible to enable through the admin UI.

- Extract headerStyle/heroDividerStyle from theme config and include in
  create and update payloads (CreateEventPage, EventDetailsPage)
- Add backend fallback to extract values from color_theme JSON when not
  explicitly provided, ensuring older clients stay in sync
2026-02-03 17:12:56 +01:00
Paul Nothaft de56cd0dce Merge pull request #165 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m18s
Build and Push Docker Images / build-frontend (push) Failing after 3m31s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.10.0-beta.0
2026-02-03 15:59:42 +01:00
github-actions[bot] 8ddec6ed8b chore(beta): release 3.10.0-beta.0 2026-02-03 14:56:50 +00:00
Paul Nothaft d9e00dc0db Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
2026-02-03 15:50:54 +01:00
Paul Nothaft 6c30e2c2ed feat: add category hero/cover photo selection (#163)
Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
2026-02-03 15:43:23 +01:00
Paul Nothaft 329d224846 fix: resolve code quality issues and add missing i18n keys (#162, #163)
Add missing i18n translations for hero image focal point picker in both
EN and DE locales. Fix lint errors across touched files: remove unused
imports/variables, replace raw buttons with shared Button component,
eliminate inline styles, extract duplicated backend validation, and
remove dead heroImagePosition type.
2026-02-03 10:53:13 +01:00
Paul Nothaft 734868abc2 feat: add hero image focal point picker with anchor positioning (#162)
Add interactive focal point picker for hero images, allowing precise
crop positioning via click or preset buttons (top/center/bottom).
Includes backend validation, migrations, and gallery rendering support.
2026-02-03 10:08:58 +01:00
Paul Nothaft f554f463b3 fix: hero header state and preview in admin theme editor (#158)
- Add hero header rendering to GalleryPreview component with divider styles
- Support event-specific header_style prop in GalleryLayout
- Pass header_style from event data to GalleryLayout in GalleryView
- Divider options now properly show/hide when switching header styles

This ensures the live preview accurately reflects hero header changes
and event-specific header styles are respected in the gallery view.
2026-02-02 23:09:36 +01:00
Paul Nothaft fa4c83812d fix: improve photo serving, category filters, and upload chunking (#155, #156, #161)
- Add try-catch and file existence check for photo path resolution (#161)
- Fix gallery categories to use photo_categories table instead of legacy type field (#156)
- Add byte-size-based chunking (500MB max) for uploads to prevent oversized batches (#155)
2026-02-02 22:55:48 +01:00
Paul Nothaft 8cc5685428 Merge pull request #160 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.9.0-beta.0
2026-02-01 22:59:20 +01:00
github-actions[bot] 7bf1e5c0f9 chore(beta): release 3.9.0-beta.0 2026-02-01 21:58:22 +00:00
Paul Nothaft 7037106bff Merge pull request #159 from the-luap/feat/new-features
feat: gallery layouts, bulk category editing, and hero header improvements
2026-02-01 22:58:08 +01:00
Paul Nothaft eca36c70a2 feat: add bulk category editing for photos (#157)
Add BulkCategoryModal component that allows selecting multiple photos
and moving them to a different category in one operation.
2026-02-01 22:48:08 +01:00
Paul Nothaft 7b8d8bd92b feat: decouple hero header from gallery layouts (#158)
- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
2026-02-01 22:44:28 +01:00
Paul Nothaft 397d33a95a fix: increase upload limit to 1GB and fix category filters (#155, #156)
- Increase nginx client_max_body_size from 100MB to 1GB for video support
- Fix admin photo category filtering to properly handle numeric category IDs
  from the photo_categories table, not just legacy 'individual'/'collage' types
- Add support for 'uncategorized' filter to show photos with no category
2026-02-01 21:07:44 +01:00
Paul Nothaft 08c2e4530e Merge pull request #154 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.8.0-beta.0
2026-01-30 08:35:15 +01:00
github-actions[bot] 9ec0e2e7c0 chore(beta): release 3.8.0-beta.0 2026-01-30 07:34:38 +00:00
Paul Nothaft aacfcd517e Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
2026-01-30 08:34:24 +01:00
Paul Nothaft 27ff51e7a1 fix: use photo dimensions for mosaic aspect ratios (#146)
Thumbnails are generated as 300x300 squares, so CSS Columns alone
couldn't show varied aspect ratios. Now using the photo's width/height
metadata with CSS aspect-ratio property to force correct proportions.
2026-01-30 08:23:58 +01:00
Paul Nothaft 821d3296ea fix: use CSS Columns for gap-free mosaic layout (#146)
Replaced CSS Grid with span rules approach with CSS Columns to eliminate
gaps and white spaces in the mosaic layout. Images now flow vertically
within columns, maintaining their natural aspect ratios without gaps.
2026-01-29 23:16:14 +01:00
Paul Nothaft 46ed1bc276 feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146)
- Add migration to backfill width/height for existing photos without dimensions
- Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio)
- Rewrite mosaic layout to use proper CSS Grid with span rules
- Fix theme not being applied after gallery login
- Improve columns mode distribution using shortest-column algorithm
- Apply gallery theme regardless of authentication status
2026-01-29 23:09:12 +01:00
Paul Nothaft 8711f967a1 fix: use actual photo aspect ratios in masonry columns mode (#146)
Previously, the Pinterest-style columns mode assigned random heights to
photos, causing landscape images to be cropped into portrait slots.
Now the height is calculated based on the photo's actual aspect ratio
and the column width, preserving natural proportions.
2026-01-29 21:40:41 +01:00
Paul Nothaft 5c8aed5793 Merge pull request #151 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.7.0-beta.0
2026-01-28 22:54:59 +01:00
github-actions[bot] c40f34d3de chore(beta): release 3.7.0-beta.0 2026-01-28 21:54:14 +00:00
Paul Nothaft ef2ae00ff2 Merge pull request #150 from the-luap/feat/new-features
feat: Add justified layout modes and aspect-ratio-aware mosaic (#146)
2026-01-28 22:53:58 +01:00
Paul Nothaft 608bbd50e7 feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options
- Implement aspect-ratio-aware mosaic layout that dynamically selects
  patterns based on photo orientations to minimize cropping
- Add 9 mosaic pattern types optimized for different orientation combinations
- Add theme customizer options for masonry mode selection (columns/rows/flickr/justified)
- Add i18n translations for new layout options
2026-01-28 22:31:34 +01:00
Paul Nothaft e3024e6ffd Merge pull request #148 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.6.0-beta.0
2026-01-27 11:47:16 +01:00
github-actions[bot] b4978c0869 chore(beta): release 3.6.0-beta.0 2026-01-27 10:45:56 +00:00
Paul Nothaft cd1d50474f Merge pull request #147 from the-luap/feat/new-features
feat: add justified/rows layout mode to masonry gallery (#146) + security fixes
2026-01-27 11:45:38 +01:00
Paul Nothaft 8097a0cb53 fix: update packages to fix security vulnerabilities
- react-router-dom 6.30.2 → 6.30.3 (XSS via Open Redirects)
- react-router 6.30.2 → 6.30.3
- @remix-run/router 1.23.1 → 1.23.2
- lodash 4.17.21 → 4.17.23 (Prototype Pollution)
2026-01-27 11:40:18 +01:00
Paul Nothaft e081b56a44 feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry:

- Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos)
- Create justifiedLayoutCalculator utility for row-based layouts
- Extract and store image dimensions on upload for layout calculations
- Include width/height in gallery API response
- Add row height and last row behavior controls to theme customizer
- Support responsive container width detection with ResizeObserver

Photos in rows mode maintain their aspect ratios while filling
horizontal rows at a consistent height. The number of photos per
row is automatically calculated based on target row height and
photo dimensions.

Closes #146
2026-01-27 09:58:09 +01:00
Paul Nothaft c2309af3e0 Merge pull request #144 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.5.0-beta.0
2026-01-25 15:25:57 +01:00
github-actions[bot] 32fc939c7a chore(beta): release 3.5.0-beta.0 2026-01-25 14:24:28 +00:00
Paul Nothaft 4c081601e0 Merge pull request #143 from the-luap/feat/new-features
feat: per-event custom logos, customizable event types, and multiple bug fixes
2026-01-25 15:24:14 +01:00
Paul Nothaft 85170b883f feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.

Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
2026-01-23 22:01:02 +01:00
Paul Nothaft c018604e5d Merge pull request #142 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.4.0-beta.0
2026-01-22 14:03:45 +01:00
github-actions[bot] 9c8b5e9fd6 chore(beta): release 3.4.0-beta.0 2026-01-22 13:00:33 +00:00
Paul Nothaft 151e1bf50f Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
2026-01-22 14:00:14 +01:00
Paul Nothaft c5a8ffc08c fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.

- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
2026-01-22 13:54:23 +01:00
Paul Nothaft d4a15dbe74 fix: remove non-functional watermark toggle from Feature Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.

This removes the dead toggle to eliminate user confusion (fixes #140).
2026-01-22 13:54:23 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

Changes include:
- Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields
- Backend routes updated to handle new settings
- Frontend admin page with logo customization controls
- HeroGalleryLayout component with dynamic logo rendering
- i18n translations for EN and DE

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft f8881d5bd6 feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom
event types beyond the default presets (wedding, birthday, corporate, other).

Backend:
- Add event_types table migration with default system types
- Create eventTypeService for CRUD operations with legacy fallback
- Add adminEventTypes routes with full REST API
- Update event validation to use dynamic event types
- Update slug generation to use custom slug_prefix

Frontend:
- Add EventTypesPage with full CRUD admin interface
- Add eventTypes.service.ts API client
- Update CreateEventPage to fetch types dynamically
- Add Event Types navigation in admin sidebar
- Add i18n translations (EN/DE)

Backward compatible: existing galleries continue to work, legacy types
accepted even if database is empty via fallback mechanisms.
2026-01-22 13:54:23 +01:00
Paul Nothaft 6b3ead747b fix: resend gallery email fails for events without password
Added optional chaining when accessing req.body.password in the
resend-email endpoint to handle cases where req.body is undefined.
This prevented the "Cannot read properties of undefined" error.

Fixes #137
2026-01-22 13:54:00 +01:00
Paul Nothaft dadef81158 fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).

Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset

Fixes #136
2026-01-22 13:54:00 +01:00
Paul Nothaft 644ea22b5f Merge pull request #135 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.3.0-beta.0
2026-01-21 17:06:49 +01:00
github-actions[bot] f4da354ae7 chore(beta): release 3.3.0-beta.0 2026-01-21 16:06:24 +00:00
Paul Nothaft a59f41463f Merge pull request #134 from the-luap/feat/optional-event-date-expiration-beta
feat: add original filename preservation and Lightroom export support
2026-01-21 17:06:06 +01:00
Paul Nothaft 9872ad3aef feat: add original filename preservation and Lightroom export support
Addresses GitHub issue #132 - enables filtering client feedback and
exporting filenames for use in Lightroom.

Changes:
- Add original_filename column to photos table via migration
- Store original filename during photo upload
- Fix export service column name mismatches (path, size_bytes, uploaded_at)
- Fix table name (photo_categories instead of categories)
- Fix toFixed() calls to handle string ratings from database

Export formats available:
- TXT with comma separator (for Lightroom Library Filter)
- CSV with full metadata
- JSON for automation
- XMP sidecar files (for Lightroom/Bridge/Capture One)
2026-01-21 16:47:13 +01:00
Paul Nothaft d0880ccb03 Merge pull request #131 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.5-beta.0
2026-01-18 15:38:03 +01:00
github-actions[bot] 237eeea5a6 chore(beta): release 3.2.5-beta.0 2026-01-18 14:35:30 +00:00
Paul Nothaft 41bf6ff884 Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta
fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
2026-01-18 15:35:15 +01:00
Paul Nothaft 991aa98f98 fix: correct invitation activation validation and add missing translations
- Fix password minimum length validation: frontend now correctly requires
  12 characters to match backend validation (was incorrectly checking for 8)
- Fix translation key references in AcceptInvitePage to use correct paths
  (e.g., acceptInvitation.errors.* instead of acceptInvitation.*)
- Add missing translations for both EN and DE:
  - contactAdminMessage
  - passwordsMatch
  - alreadyHaveAccount
  - signIn

Fixes #129
2026-01-18 15:00:38 +01:00
Paul Nothaft 86fa1046d5 fix: correct invitation email link URL path
The invitation email was generating links to /admin/accept-invite/{token}
but the frontend route is configured at /invite/{token}. This caused
invited users to see a blank page when clicking the email link.

Fixes #129
2026-01-18 12:55:56 +01:00
Paul Nothaft 3397807670 docs: emphasize importance of STORAGE_PATH in env example 2026-01-17 15:48:24 +01:00
Paul Nothaft cdda709886 fix: add STORAGE_PATH to production docker-compose
Ensures STORAGE_PATH environment variable is explicitly set in
production deployments to prevent path resolution issues when
serving thumbnails and other storage-related operations.
2026-01-17 15:48:15 +01:00
Paul Nothaft 023bb97e66 Merge pull request #128 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.4-beta.0
2026-01-17 15:08:48 +01:00
github-actions[bot] cf38305f28 chore(beta): release 3.2.4-beta.0 2026-01-17 14:08:09 +00:00
Paul Nothaft 0e3674b2b0 Merge pull request #127 from the-luap/feat/optional-event-date-expiration-beta
fix: correct storage path resolution in multiple files (#96)
2026-01-17 15:07:56 +01:00
Paul Nothaft 3ccb8154eb fix: correct storage path resolution in multiple files (#96)
Fixed inconsistent storage path fallbacks that caused 500 errors when
serving thumbnails. The paths were using '../../storage' (2 levels up)
instead of '../../../storage' (3 levels up) when STORAGE_PATH env var
is not set.

Affected files:
- backend/src/routes/gallery.js
- backend/src/services/photoService.js
- backend/src/services/eventService.js
2026-01-17 14:01:29 +01:00
Paul Nothaft b5ac18121d Merge pull request #126 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.3-beta.0
2026-01-16 15:23:29 +01:00
github-actions[bot] b613f8fbc7 chore(beta): release 3.2.3-beta.0 2026-01-16 14:19:26 +00:00
Paul Nothaft cacaffa5c3 Merge pull request #125 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button not visible in gallery (#113)
2026-01-16 15:19:09 +01:00
Paul Nothaft 691e3aba09 fix: add allow_user_uploads to gallery API responses
The gallery /photos and /info endpoints were not returning the
allow_user_uploads field, causing the upload button to never show
in the frontend since the value was always undefined/false.
2026-01-16 15:15:09 +01:00
Paul Nothaft 70a0caa11f Merge pull request #124 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.2-beta.0
2026-01-16 14:53:44 +01:00
github-actions[bot] e808e529cd chore(beta): release 3.2.2-beta.0 2026-01-16 13:53:31 +00:00
Paul Nothaft 05a5307e22 Merge pull request #123 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:53:17 +01:00
Paul Nothaft 2a2c23d116 fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:49:37 +01:00
Paul Nothaft a092d98523 Merge pull request #122 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.1-beta.0
2026-01-16 14:38:39 +01:00
github-actions[bot] b5f06af126 chore(beta): release 3.2.1-beta.0 2026-01-16 13:35:53 +00:00
Paul Nothaft 6cb43428d1 Merge pull request #121 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:35:36 +01:00
Paul Nothaft df7dbffbff fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:29:49 +01:00
Paul Nothaft 94421a6b12 Merge pull request #120 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.2.0-beta.0
2026-01-16 09:43:51 +01:00
github-actions[bot] 7805e89bfe chore(beta): release 3.2.0-beta.0 2026-01-16 08:43:36 +00:00
Paul Nothaft 3079eaa2e5 Merge pull request #119 from the-luap/feat/optional-event-date-expiration-beta
feat: add optional event date and expiration settings
2026-01-16 09:43:23 +01:00
Paul Nothaft 2151147f2d feat: add optional event date and expiration settings
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
2026-01-16 09:39:32 +01:00
Paul Nothaft 3e69579f5a docs: add API_URL environment variable to .env.example files
Document the API_URL environment variable that is used for constructing
URLs for assets (logos, images) in email notifications. Without this
setting, the system defaults to http://localhost:3001 which causes
broken images in production emails.

Added to both root and backend .env.example files with clear
documentation about its purpose and importance.
2026-01-16 09:39:32 +01:00
Paul Nothaft 808ed1d2f1 fix: checkbox and toggle settings not persisting after page refresh
PostgreSQL's json column type returns parsed values directly (boolean
false instead of string "false"). The backend code used a truthy check
which failed for boolean false values, causing null to be returned
instead of the actual false value.

Changed condition from `if (setting.setting_value)` to explicit null
check `if (setting.setting_value !== null && setting.setting_value !== undefined)`
and added handling for already-parsed json column values.

Fixes #117
2026-01-16 09:39:32 +01:00
Paul Nothaft b40e085d28 Merge pull request #116 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m18s
Build and Push Docker Images / build-frontend (push) Failing after 3m15s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.1.0-beta.0
2026-01-15 15:04:17 -05:00
github-actions[bot] d603567e21 chore(beta): release 3.1.0-beta.0 2026-01-15 20:03:57 +00:00
Paul Nothaft c6fdd38e84 Merge pull request #115 from the-luap/fix/codeql-v4-upgrade
feat: pre-generated watermarks and mobile upload button improvements
2026-01-15 15:03:25 -05:00
Paul Nothaft ae181cf92f fix: show upload button in mobile topbar instead of sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.

- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar

Fixes #113
2026-01-15 21:00:17 +01:00
Paul Nothaft 1be974afbb feat: pre-generate watermarks for instant lightbox loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.

This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).

- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft 4c0baf242b Merge pull request #114 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m57s
Build and Push Docker Images / build-frontend (push) Failing after 3m47s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.4.0
2026-01-15 14:18:51 -05:00
github-actions[bot] b12621b994 chore(main): release 2.4.0 2026-01-15 19:18:31 +00:00
Paul Nothaft 4701edc12e Merge pull request #112 from the-luap/fix/codeql-v4-upgrade
fix: dynamic website title from branding settings
2026-01-15 14:18:11 -05:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 41f80fc898 Merge pull request #111 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m17s
Build and Push Docker Images / build-frontend (push) Failing after 3m42s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.4
2026-01-15 10:24:59 -05:00
github-actions[bot] 0f7551ab5b chore(main): release 2.3.4 2026-01-15 15:24:23 +00:00
Paul Nothaft 7c58749806 Merge pull request #110 from the-luap/fix/codeql-v4-upgrade
fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
2026-01-15 10:24:04 -05:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m36s
Build and Push Docker Images / build-frontend (push) Failing after 3m21s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m6s
Build and Push Docker Images / build-frontend (push) Failing after 3m56s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m53s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
Paul Nothaft 1c8f686c19 Merge pull request #102 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m23s
Build and Push Docker Images / build-frontend (push) Failing after 3m8s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.1-beta.0
2026-01-15 06:36:13 -05:00
github-actions[bot] a0f38053d3 chore(beta): release 3.0.1-beta.0 2026-01-15 11:35:58 +00:00
Paul Nothaft cb012186d9 Merge pull request #101 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:35:46 -05:00
Paul Nothaft fe7d45dd12 fix: use Release Please extra-files instead of sync-versions job
Remove sync-versions job that fails on protected branches.
Instead, use Release Please's extra-files feature to update
package.json versions as part of the release PR.
2026-01-15 12:32:07 +01:00
Paul Nothaft c05ae5b0b9 chore: upgrade CodeQL Action from v3 to v4
Address deprecation warning - CodeQL Action v3 will be deprecated in December 2026.
2026-01-15 12:30:25 +01:00
Paul Nothaft dab012c3d1 Merge pull request #100 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Failing after 3m31s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(beta): release 3.0.0-beta.0
2026-01-15 06:28:27 -05:00
github-actions[bot] 32492c5a91 chore(beta): release 3.0.0-beta.0 2026-01-15 11:24:18 +00:00
github-actions[bot] 2add85eccf chore: sync package.json versions to 2.3.0 2026-01-15 11:19:05 +00:00
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 4m13s
Build and Push Docker Images / build-frontend (push) Failing after 3m30s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

Release Channels:
- Stable channel: production-ready releases (stable, latest, v2.3.0)
- Beta channel: early access features (beta, v2.3.0-beta.1)
- Configurable via PICPEAK_CHANNEL environment variable

Update Notifications:
- Admin dashboard shows available updates for configured channel
- Checks GitHub Releases API with 1-hour cache
- Can be disabled with UPDATE_CHECK_ENABLED=false

CI/CD Changes:
- New release-please-beta.yml workflow for beta prereleases
- Docker build workflow produces stable/beta tags based on branch
- Beta versions use v2.3.0-beta.1 format

New Files:
- .github/workflows/release-please-beta.yml
- release-please-config-beta.json
- .release-please-manifest-beta.json
- backend/src/services/updateCheckService.js
- frontend/src/components/admin/UpdateNotification.tsx

Modified Files:
- docker-compose.production.yml (channel selection)
- .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED)
- backend/src/routes/adminSystem.js (/updates endpoint)
- frontend components (VersionInfo, AdminDashboard)
- i18n locales (en.json, de.json)
- README.md and DEPLOYMENT_GUIDE.md (documentation)
2026-01-15 12:11:06 +01:00
Paul Nothaft e3c3c4c951 fix: gallery thumbnails not loading (404 errors) #96
The gallery thumbnail endpoint was returning 404 when thumbnail_path
was null or the file didn't exist, unlike the admin endpoint which
generates thumbnails on demand using ensureThumbnail().

- Import ensureThumbnail from imageProcessor
- Use ensureThumbnail() in gallery thumbnail route to generate
  thumbnails on demand if they don't exist
- This matches the admin endpoint behavior

Fixes #96
2026-01-15 11:22:13 +01:00
Paul Nothaft 0e3b50d1b6 fix: watermark upload JSON parsing and image quality preservation
- Fix JSON parsing error when uploading watermark logo by handling both
  JSON-stringified and raw string paths
- Ensure publicPath is JSON.stringify'd consistently when saving
- Preserve original image format (PNG/WebP/JPEG) when applying watermarks
- Use maximum quality (100) to prevent unnecessary recompression
2026-01-12 13:24:21 +01:00
Paul Nothaft bd8b885f7f fix: display new password after admin password reset
- show-admin-credentials.js --reset now displays the generated password
  instead of just saying "[NEWLY RESET - stored in database]"
- Also sets must_change_password flag to force password change on login
- Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the
  new password is displayed in console output after reset
2026-01-12 13:23:24 +01:00
Paul Nothaft 3cdc0ea715 fix: prevent unnecessary image recompression and fix SQLite migration #95
- Skip image processing for basic/standard protection levels when no
  fingerprinting or watermarking is enabled
- Preserve original image format (PNG/WebP/JPEG) instead of always
  converting to JPEG
- Fix SQLite migration failure for fresh installations by adding
  multilingual columns to email_templates table before inserting
  admin email templates

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
Paul Nothaft 3f7631cd95 Merge pull request #93 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m9s
Build and Push Docker Images / build-frontend (push) Failing after 3m9s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.4
2026-01-08 23:23:57 +01:00
github-actions[bot] 53b8764ed7 chore(main): release 2.2.4 2026-01-08 22:22:51 +00:00
Paul Nothaft 082d8ab205 fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
fix: Docker Swarm DNS resolution and backup status display (v2.2.3)
2026-01-08 23:22:33 +01:00
Paul Nothaft 749100c92a fix(backup): add lastBackup alias and totalBackups for frontend compatibility
The frontend expected `status.lastBackup` but the backend was returning
`status.lastRun`. This caused the backup dashboard to show "No backup available"
even when backups existed in the history.

Added:
- `lastBackup` as alias for `lastRun`
- `totalBackups` count of completed backups
2026-01-08 23:11:48 +01:00
Paul Nothaft 3798662722 Merge pull request #91 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m22s
Build and Push Docker Images / build-frontend (push) Failing after 3m0s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.3
2026-01-08 18:26:58 +01:00
github-actions[bot] fa1397cb8c chore(main): release 2.2.3 2026-01-08 17:26:11 +00:00
Paul Nothaft cc1ddfd42c fix(nginx): Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3)
Fixes 502 Bad Gateway on root path in Docker Swarm by adding DNS resolver
  configuration (127.0.0.11) and dynamic DNS resolution for all proxy_pass 
  directives. This ensures nginx resolves backend service IPs on each request 
  rather than caching them at startup.
2026-01-08 18:25:54 +01:00
Paul Nothaft 049837f9d6 fix(nginx): add Docker DNS resolver for Swarm/dynamic service discovery
- Add resolver 127.0.0.11 directive for Docker's internal DNS
- Use variable-based proxy_pass to force per-request DNS resolution
- Fix 502 Bad Gateway error on root path in Docker Swarm deployments

The issue was that nginx caches DNS lookups at startup, but in Docker
Swarm where service IPs can change dynamically, this caused stale DNS
entries leading to 502 errors for proxied requests.

Bumps version to 2.2.3
2026-01-08 16:25:05 +01:00
Paul Nothaft 29dc2a3cf1 Merge pull request #89 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m23s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.2
2026-01-08 15:53:44 +01:00
github-actions[bot] d2cba449b0 chore(main): release 2.2.2 2026-01-08 14:51:24 +00:00
Paul Nothaft e0bd19a74d fix: Align nginx backend port for production Docker deployments (v2.2.2) (#88)
Fix 502 Bad Gateway on root path in production Docker/Traefik deployments.

  - nginx.conf: backend:3001 → backend:3000 (matches production container port)
  - docker-compose.yml: align dev environment to use port 3000
  - Bump version to 2.2.2
2026-01-08 15:51:10 +01:00
Paul Nothaft 0ab8cbde7f chore: bump version to 2.2.2
Includes fix for nginx backend port alignment (3001 → 3000) that caused
502 errors on root path in production Docker deployments.
2026-01-08 15:46:41 +01:00
Paul Nothaft 3a8d53f492 fix: align backend port to 3000 across all configurations
The production docker-compose used port 3000 internally but nginx.conf
was hardcoded to port 3001, causing 502 errors on the root path (/).

Changes:
- Update nginx.conf to use backend:3000
- Update docker-compose.yml to use PORT=3000 for consistency
- Update port mapping and healthcheck to use port 3000
2026-01-08 15:28:35 +01:00
Paul Nothaft 804a964ba0 Merge pull request #87 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 2m58s
Build and Push Docker Images / build-frontend (push) Failing after 3m4s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.1
2026-01-08 14:01:58 +01:00
github-actions[bot] d2cd1aa933 chore(main): release 2.2.1 2026-01-08 13:01:36 +00:00
Paul Nothaft d7ecf83d32 fix: Resolve branding display issues and invitation parsing errors (v2.2.1) (#86)
Fixes #84, Fixes #85
  - Fix uploads proxy routing in nginx and vite dev server
  - Fix logo/favicon state handling in BrandingPage
  - Fix invitation API response field transformation (snake_case → camelCase)  
  - Add hide_powered_by to public settings API
  - Mark Multiple Administrators as implemented in roadmap
  - Bump version to 2.2.1
2026-01-08 14:01:21 +01:00
Paul Nothaft ebb2ce6065 Merge branch 'main' into feature/multiple-administrators 2026-01-08 13:58:41 +01:00
Paul Nothaft 1931d73b60 fix: resolve branding display issues and invitation parsing errors
Fixes #84 - Logo and favicon not displaying on branding page and galleries
Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors

Changes:
- Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching
- Fix vite.config.ts: Add /uploads proxy for development environment
- Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme
- Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response
- Fix publicSettings.js: Add branding_hide_powered_by to public settings API response
- Update README.md: Mark Multiple Administrators feature as implemented
- Bump version to 2.2.1
2026-01-08 13:56:02 +01:00
Paul Nothaft 0d5ce48dcc fix: handle legacy non-JSON logo paths when replacing logo
When uploading a new logo, the code tries to delete the old logo file.
This failed when the old path was stored as a raw path (legacy format)
instead of JSON-serialized. Added check to handle both formats.
2026-01-08 11:44:29 +01:00
Paul Nothaft 4872ef71f8 ci: only build ARM64 images for tagged releases
QEMU emulation of ARM64 on x86 GitHub runners is too slow and
unreliable for npm operations, causing builds to hang or crash
with "Illegal instruction" errors.

Changed platform detection logic to:
- Tagged releases (v*.*.*): Build both amd64 and arm64
- All other builds (branches, PRs): Build amd64 only

This ensures fast CI feedback during development while still
providing multi-arch images for production releases.
2026-01-08 11:33:20 +01:00
Paul Nothaft b83f4272b5 fix: JSON serialize favicon and logo URLs for PostgreSQL storage
Fixes #84

The favicon and logo upload endpoints were storing URL paths directly
without JSON.stringify(), causing PostgreSQL JSON validation errors
("Token '/' is invalid") since paths like "/uploads/favicons/..."
are not valid JSON.

Applied JSON.stringify() to:
- branding_logo_url setting (lines 358, 364)
- branding_favicon_url setting (lines 894, 900)
2026-01-08 11:29:38 +01:00
github-actions[bot] 5df64992c4 chore: sync package.json versions to 2.2.0 2026-01-08 09:28:03 +00:00
Paul Nothaft 7e5e004270 Merge pull request #83 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m5s
Build and Push Docker Images / build-frontend (push) Failing after 3m5s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.2.0
2026-01-08 10:27:40 +01:00
github-actions[bot] 09ce2b80d0 chore(main): release 2.2.0 2026-01-08 09:26:48 +00:00
Paul Nothaft 476fcce13f fix: Add settings translations and fix manual backup process (#82)
- Add i18n translations for settings tabs (Events, Image Security, Moderation, CSS)
  - Fix manual backup when automated backups are disabled
  - Fix PostgreSQL wait-for-db.sh connection check
2026-01-08 10:26:34 +01:00
Paul Nothaft c030e87213 feat(i18n): add translations for settings tabs
- Add settings.events.* keys for Event Creation settings
- Add settings.imageSecurity.* keys for Image Protection settings
- Add settings.moderation.* keys for Word Filter/Moderation settings
- Add cssTemplates.* keys for Custom CSS Templates
- All settings tabs now have proper i18n support
2026-01-07 22:43:36 +01:00
Paul Nothaft e6dd89e969 fix(backup): allow manual backups when automated backups are disabled
- Manual backup button now works regardless of backup_enabled setting
- backup_enabled only controls scheduled/automated backups
- Manual backups only require destination to be configured
- Fixed backup_type to correctly show 'manual' vs 'scheduled'
2026-01-07 22:39:57 +01:00
Paul Nothaft e85a68a386 fix(db): improve PostgreSQL connection check in wait-for-db.sh
- Try connecting to target database first (most common case)
- Fall back to template1 instead of postgres database for checks
- The picpeak user may not have access to postgres system database
- Add better retry logic with max attempts
- Improve error messages
2026-01-07 22:33:40 +01:00
github-actions[bot] 0acce6ab08 chore: sync package.json versions to 2.1.1 2026-01-07 21:24:07 +00:00
Paul Nothaft 92a1c7a2df Merge pull request #81 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m44s
Build and Push Docker Images / build-frontend (push) Failing after 3m2s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.1
2026-01-07 22:23:40 +01:00
github-actions[bot] edc57bfbbe chore(main): release 2.1.1 2026-01-07 21:23:23 +00:00
Paul Nothaft 37d4e1cb61 fix: Multi-administrator RBAC, CSS templates & security hardening (#80)
- Add multi-administrator support with role-based access control (RBAC)
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing page
  - Fix photo category selection during upload (#77)
  - Fix category changes not persisting (#77)
  - Improve feedback button visibility in gallery views (#77)
  - Security hardening: upgrade Alpine base image, fix CVEs
  - Add Release Please for automated versioning
  - Fix Docker multi-arch builds with proper QEMU setup
  - Add Photo and Settings service layers
  - Fix date parsing and Vite proxy configuration
  - Fix S3 backup/restore functionality
2026-01-07 22:23:10 +01:00
Paul Nothaft 0d36a273bb fix(ci): add QEMU setup for multi-arch builds and skip for PRs
- Add docker/setup-qemu-action for proper ARM64 emulation
- Skip QEMU setup for PR builds (amd64 only)
- Fix QEMU "Illegal instruction" errors during npm ci
2026-01-07 22:17:33 +01:00
github-actions[bot] a19e218e40 chore: sync package.json versions to 2.1.0 2026-01-07 20:54:46 +00:00
Paul Nothaft 61c53fb24e Merge pull request #79 from the-luap/release-please--branches--main
Build and Push Docker Images / build-frontend (push) Failing after 5m35s
Build and Push Docker Images / build-backend (push) Failing after 43m28s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.1.0
2026-01-07 21:54:20 +01:00
github-actions[bot] dc8cf9a9a2 chore(main): release 2.1.0 2026-01-07 20:53:40 +00:00
Paul Nothaft 16b3ab039a feat: Multi-administrator RBAC, CSS templates & security hardening (#78)
- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
2026-01-07 21:53:25 +01:00
Paul Nothaft 6a6c2cd34d feat(events): add CSS template selector to event edit page
- Add CSS template selector to ThemeCustomizerEnhanced component
- Rename "Custom CSS" to "Event-specific Custom CSS" for clarity
- Load and save css_template_id when editing events
- Fetch CSS templates when entering edit mode on EventDetailsPage
- Pass CSS template props to ThemeEditorModal
- Add backend validation for css_template_id field
2026-01-07 18:04:23 +01:00
Paul Nothaft 856d53343c fix(photos): resolve upload category selection and improve feedback buttons (#77)
- Fix upload category selection by looking up category from database
  and saving category_id to photos table (was being ignored before)
- Use category slug for filename generation during upload
- Improve Like/Comment button visibility in CarouselGalleryLayout and
  PhotoLightbox with semi-transparent background and border styling
2026-01-07 17:46:46 +01:00
Paul Nothaft d9da98c355 fix(photos): category changes now persist and display correctly (#77)
- Backend PATCH /photos/:photoId now returns updated photo object
- Photo listing now joins with photo_categories table to get actual
  category name and slug instead of hardcoding based on photo.type
- Frontend service now properly returns AdminPhoto from update response

Fixes #77
2026-01-07 17:31:19 +01:00
Paul Nothaft 892e47d017 feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
2026-01-07 17:10:46 +01:00
github-actions[bot] 007e46edb9 chore: sync package.json versions to 2.0.0 2026-01-03 22:57:16 +00:00
Paul Nothaft 542887c2e5 Merge pull request #74 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 44m27s
Build and Push Docker Images / build-frontend (push) Failing after 44m16s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.0.0
2026-01-03 23:56:46 +01:00
github-actions[bot] 4651783d4d chore(main): release 2.0.0 2026-01-03 22:52:59 +00:00
Paul Nothaft b706eeb5d3 fix(security): upgrade Alpine base image to fix libpng and c-ares CVEs
Update frontend Dockerfile to use nginx:1.27-alpine3.22 which includes:
- libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
- c-ares >= 1.34.5 (fixes CVE-2025-31498)

Remove redundant edge repository pull since Alpine 3.22 packages are already patched.
2026-01-03 23:52:38 +01:00
Paul Nothaft 40ee67171d Merge pull request #73 from the-luap/feature/event-rename
feat: add event management, gallery customization, and release automationFeature/event rename
2026-01-03 23:39:54 +01:00
Paul Nothaft 6033461be1 feat: add Apple Liquid Glass templates, image security settings, and automated releases
## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
2026-01-03 23:35:23 +01:00
Paul Nothaft f3c2cee362 security: Fix critical vulnerabilities and harden application
## Security Fixes

### CRITICAL: Command Injection (adminBackup.js)
- Replaced exec() with spawn() using argument arrays
- Added input sanitization for host, user, and ssh_key
- Added regex validation for hostname/IP format
- Added username format validation
- Added SSH key file existence check
- Prevents shell metacharacter injection attacks

### HIGH: Hardcoded Password (set-admin-password.js)
- Removed hardcoded 'admin123' password
- Now requires password as CLI argument or env variable
- Added password strength validation (8+ chars, mixed case, numbers, special chars)
- Added --help flag with usage instructions
- Invalidates existing sessions on password change

### MEDIUM: XSS Vulnerability (WelcomeMessageEditor.tsx)
- Added DOMPurify sanitization to getPreviewHtml()
- Strips all HTML tags before rendering preview
- Prevents script injection in admin preview

### LOW: Sample Password Exposure (EmailConfigPage.tsx)
- Replaced plaintext sample password with masked placeholder
- Uses '••••••••' instead of realistic password

## Dependency Updates
- Fixed npm audit vulnerabilities (jws, qs, express)
- Backend: 0 vulnerabilities
- Frontend: 0 vulnerabilities
2026-01-03 10:12:01 +01:00
Paul Nothaft 0da45e699a feat: Add CSS template system with custom gallery styling support
## Changes

### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing

### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements

### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background

### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)

### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
2026-01-03 08:59:01 +01:00
Paul Nothaft 97455ab047 Fix date parsing bug and Vite proxy port configuration
- Add safeParseDate helper to handle dates that may be strings, Date objects, or timestamps
- Replace all parseISO(event.*) calls with safeParseDate() to prevent "dateString.split is not a function" errors
- Fix Vite proxy target from port 3002 to 3001 to match backend server port
2026-01-02 10:49:42 +01:00
Paul Nothaft fbd7b67016 refactor: Add Photo and Settings service layers
Phase 2.2: Photo service layer
- Create backend/src/services/photoService.js
- Functions: getPhotosForEvent, getPhotoById, getPhotoCount
- Functions: updatePhoto, deletePhoto, bulkDeletePhotos
- Functions: updateSortOrder, moveToCategory, setHeroPhoto
- Support for soft delete and hard delete with file cleanup

Phase 2.3: Settings service layer
- Create backend/src/services/settingsService.js
- Functions: getAllSettings, getSetting, getSettingsByPrefix
- Functions: updateSetting, updateSettings, deleteSetting
- Functions: getPublicSettings, getBrandingSettings, getEmailSettings
- Type-aware setting parsing (boolean, number, json, string)

All core service layers are now established for:
- Events (CRUD, slug generation, expiration)
- Photos (CRUD, categories, sorting, hero)
- Settings (typed get/set, prefix queries)

Routes can be incrementally migrated to use these services.
2026-01-02 10:16:55 +01:00
Paul Nothaft 3424bd22ee refactor: Phase 1 code consolidation and service layer setup
Phase 1.1: Shared parsers utility
- Create backend/src/utils/parsers.js with parseBooleanInput, parseStringInput, etc.
- Create frontend/src/utils/parsers.ts with TypeScript equivalents
- Update routes to import from shared parsers

Phase 1.2: Auth routes consolidation
- Merge auth.js, auth-enhanced.js, auth-enhanced-v2.js into single auth.js
- Add password change and password strength endpoints
- Consolidate middleware (auth.js with token revocation support)
- Update all imports across 14+ route files

Phase 1.3: CreateEvent page consolidation
- Remove duplicate CreateEventPage.tsx (basic version)
- Rename CreateEventPageEnhanced.tsx to CreateEventPage.tsx
- Update exports and imports

Phase 1.4: CMS page consolidation
- Remove duplicate CMSPage.tsx (basic version)
- Rename CMSPageEnhanced.tsx to CMSPage.tsx
- Update exports and imports

Phase 1.5: Multer config factory
- Create backend/src/config/multerConfig.js
- Centralized upload configuration with presets for photos, logos, favicons
- Reusable helpers: createDiskStorage, createFileFilter, uploadTimeoutMiddleware

Phase 2.1: Event service layer
- Create backend/src/services/eventService.js
- Move event business logic out of routes
- Functions: createEvent, getAllEvents, updateEvent, deleteEvent, extendExpiration
2026-01-02 10:12:24 +01:00
Paul Nothaft 77a4bfd499 feat: implement 4 new features with bug fixes and refactoring plan
## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
2026-01-02 09:56:19 +01:00
Paul Nothaft 64ceb20431 Add planning document for photo filtering and export feature
Comprehensive feature plan for filtering photos by guest feedback
(ratings, likes, favorites) and exporting selections for professional
photo editing workflows.

Export formats supported:
- TXT: Simple filename list for Lightroom filter paste
- CSV: Spreadsheet with metadata columns
- XMP: Sidecar files with ratings/labels for Lightroom/Capture One
- ZIP: Original photos with folder organization
- JSON: Structured metadata for automation

Key features:
- Admin filter UI with rating thresholds and feedback toggles
- AND/OR filter logic
- Quick presets (Guest Picks, Top Rated, Most Popular)
- Photo selection with batch actions
- XMP rating mapping (PicPeak 1-5 → XMP 1-5 + color labels)
- Background job support for large exports
- Export settings dialog with customization options

Research references:
- Adobe XMP/Lightroom metadata standards
- Capture One EIP format
- IPTC Photo Metadata Standard
- ExifTool capabilities
2026-01-02 00:00:00 +01:00
Paul Nothaft e0204aeeee Add planning document for optional event contact fields
Addresses GitHub issue #60 - making customer name, customer email,
and admin email fields optional when creating events.

This feature adds three new admin settings:
- event_require_customer_name (default: true)
- event_require_customer_email (default: true)
- event_require_admin_email (default: true)

Implementation includes:
- Database migration for new app_settings entries
- Backend conditional validation in event creation
- Frontend settings UI with toggle switches
- Dynamic form validation based on settings
- Warning messages for email-related settings
- Graceful handling of empty contact fields

Maintains backward compatibility with default behavior unchanged.
2026-01-01 23:52:07 +01:00
Paul Nothaft 7df481f7ea Add planning document for event rename feature
This document outlines the implementation plan for allowing administrators
to rename gallery events with full Option B implementation:

- Database updates (events, photos, new slug_redirects table)
- File system changes (folders and photo files)
- New API endpoint: POST /api/admin/events/:id/rename
- Frontend UI components (button, dialog, progress indicator)
- Email notification option for resending invitation
- Slug redirect support for backward compatibility
- Transaction handling with rollback mechanism

The feature includes:
- Rename button on event detail page
- Confirmation dialog with new name input
- Real-time slug preview
- Checkbox to resend invitation email
- Progress indicator during operation
- Redirect to renamed event on success
2026-01-01 23:50:28 +01:00
Paul Nothaft 03bd6cef93 Merge pull request #72 from criticalsool/patch-1
FIX BUG Syntax Error
2025-11-30 13:57:40 +01:00
Critical Sool da5ae0ef10 Update server.js 2025-11-29 15:32:42 +01:00
paul 7c7498385f Regenerate frontend package-lock to match package.json
Build and Push Docker Images / build-backend (push) Failing after 2m44s
Build and Push Docker Images / build-frontend (push) Failing after 11s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 18:44:38 +01:00
paul 1ae63890ff Fetch patched libpng from edge for frontend runtime
Build and Push Docker Images / build-backend (push) Failing after 15m39s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 17:54:56 +01:00
Claude 5f1affafd8 Update frontend package-lock.json for npm compatibility
Regenerate lock file to include missing esbuild platform dependencies
required by newer npm versions.
2025-11-28 17:54:56 +01:00
Claude 8315c11d34 Update backend package-lock.json for npm compatibility
Regenerate lock file to include missing transitive dependencies
(encoding, iconv-lite) required by newer npm versions.
2025-11-28 17:54:36 +01:00
Claude 0043f2aaf4 Fix npm ci command for newer npm versions
Replace deprecated --only=production with --omit=dev flag
which is required for npm 10+ after the npm upgrade.
2025-11-28 17:54:36 +01:00
Claude d494eda301 Fix glob CVE-2025-64756 security vulnerability in Docker images
Upgrade npm to latest version in both backend and frontend Dockerfiles
to fix the command injection vulnerability in glob's CLI (CVE-2025-64756).
The vulnerability exists in npm's bundled glob package (< 10.5.0 or < 11.1.0).
2025-11-28 17:54:36 +01:00
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00
Claude 77326a91ca Apply critical bug fixes from main to prevent merge regressions
This commit applies essential bug fixes from main branch to ensure no
regressions occur when merging the video-support branch:

1. Increase body parser limits from 100mb to 10gb for large video uploads
   - Updated express.json and express.urlencoded limits in server.js

2. Rename video migration from 047 to 048 to avoid conflict
   - Main branch already has 047_add_tls_reject_unauthorized.js
   - Prevents migration system from skipping one of the migrations

3. Fix category update logic with proper validation
   - Add updated_at timestamp to all category updates
   - Add explicit null handling for category_id
   - Add parseInt with radix parameter for numeric IDs
   - Add isNaN validation to prevent invalid values
   - Fix event_id constraint in single photo update query
   - Add parseInt to photoCount comparison for type safety

These fixes ensure all bug fixes from main branch (especially from
commit d91ab43) are preserved when the PR is merged.
2025-11-28 17:54:36 +01:00
Claude 0d95eab86a Add chunked upload support for large video files up to 10GB
- Increased max file size from 500MB to 10GB
- Created chunkedUploadService.js for managing chunked uploads
- Added chunked upload API endpoints (init, chunk, complete, status, abort)
- Added frontend chunked upload methods to photos.service.ts
- Files >100MB automatically use chunked uploads
- 10MB chunk size for reliable transfers
- Auto-cleanup of expired uploads after 24 hours
- Updated README with 10GB limit and nginx configuration example
2025-11-28 17:53:56 +01:00
Claude f3482a9a78 Update README with video support requirements and status
- Added Video Support Requirements section with resource recommendations
- Noted FFmpeg is bundled via npm (no system installation required)
- Listed supported formats and max file size
- Updated roadmap to mark Video Support as implemented
2025-11-28 17:53:56 +01:00
Claude 68a9dc5749 Add comprehensive video support to galleries
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
2025-11-28 17:53:56 +01:00
paul 8c87f1537b Resolve merge conflicts for video uploads and processing 2025-11-28 17:52:42 +01:00
paul 97e54355fb Update frontend runtime image to patched libpng
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-28 17:47:24 +01:00
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
paul bce5f749b1 Merge remote-tracking branch 'upstream/main'
Build and Push Docker Images / build-backend (push) Failing after 13m5s
Build and Push Docker Images / build-frontend (push) Failing after 2m55s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-25 22:43:48 +02:00
Paul Nothaft 584cfb11df Merge pull request #65 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Fix security vulnerabilities detected by Trivy
2025-11-25 21:41:45 +01:00
Claude f327f4cbcd Update package-lock.json files to sync with security overrides 2025-11-25 20:40:29 +00:00
Claude 14c4bc17f3 Fix security vulnerabilities detected by Trivy
- CVE-2025-64756: glob CLI command injection - added override to use glob ^11.1.0
- CVE-2025-13466: body-parser DoS - added override to use body-parser ^2.2.1
- CVE-2025-64718: js-yaml prototype pollution - updated to js-yaml ^4.1.1
- BusyBox vulnerabilities (netstat, tar) - added apk upgrade to all Dockerfiles

Changes:
- backend/package.json: Updated js-yaml, added overrides for glob, body-parser
- frontend/package.json: Added overrides for glob, js-yaml
- All Dockerfiles: Added 'apk upgrade --no-cache' to get latest security patches
- backend/Dockerfile.dev: Updated from node:18-alpine to node:20-alpine
2025-11-25 20:35:59 +00:00
Paul Nothaft 3d0a4564b6 Merge pull request #64 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Add option to ignore SSL/TLS certificate errors for email (Issue #53)
2025-11-25 21:31:22 +01:00
Claude e85d1bf72a Add option to ignore SSL/TLS certificate errors for email (Issue #53)
This feature allows users with non-standard SMTP setups (shared hosting,
self-signed certificates) to bypass certificate validation when needed.

Changes:
- Add database migration for tls_reject_unauthorized column
- Update emailProcessor.js to pass TLS option to nodemailer
- Update adminEmail.js routes to handle the new field
- Add checkbox UI with security warning in EmailConfigPage
- Add English and German translations
2025-11-25 20:23:39 +00:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul a971eee7b9 Merge remote-tracking branch 'origin/main'
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2025-11-25 22:03:02 +02:00
paul 8e8dd358bf Merge remote-tracking branch 'upstream/main' 2025-11-25 22:02:23 +02:00
Paul Nothaft ee1aa7e5cb Merge pull request #63 from the-luap/claude/prioritize-bugs-01QQsR6rU9MKPE7jEy2Ey8dM
Fix multiple bugs: thumbnail generation, branding settings, categorie…
2025-11-25 20:59:35 +01:00
Claude f446335e81 Fix CI/CD: Build amd64 only for PRs to avoid QEMU ARM64 emulation issues
Sharp library native binaries cause QEMU 'Illegal instruction' errors during
ARM64 emulation. This change builds only amd64 for PR checks (faster, reliable)
while maintaining multi-arch (amd64+arm64) builds for main/develop/tags.
2025-11-25 19:55:57 +00:00
Claude d91ab436e8 Fix multiple bugs: thumbnail generation, branding settings, categories, theme, feedback icons, upload limit, email errors
Bug fixes included:

#52 - Thumbnail Generation: Added proper parsing of settings values and validation
      of Sharp fit parameter to handle JSON-encoded strings correctly

#61 - Branding Settings Not Persisting: Added _parseBoolean helper for reliable
      boolean parsing, added hide_powered_by option for white-label support

#55 - Categories Not Applied: Fixed category update logic to properly handle
      numeric category IDs, added updated_at timestamp, improved cache invalidation

#59/#56 - Gallery Layout & Apply Theme: Set isPreviewMode=true so theme changes
      immediately propagate to parent state, hidden redundant Apply button

#58 - Feedback Icons Show When Disabled: Added feedbackEnabled check to comment
      and like buttons in MasonryGalleryLayout and GridGalleryLayout

#57 - Upload Limit 100MB: Increased body parser limit from 100MB to 500MB to
      support larger batch uploads

#54 - Wrong Error Message: Enhanced email error handling with specific error
      codes and translation keys for better user feedback
2025-11-25 19:31:07 +00:00
Paul Nothaft 0745b11745 Merge pull request #51 from the-luap/claude/fix-issues-49-50-01Rqwe1uhvLpbZ64tA5eiB2H
Fix issues #49 and #50: Migration errors and missing worker manager
2025-11-19 23:05:48 +01:00
Claude 97589a7c5f Fix issues #49 and #50: Migration errors and missing worker manager
- Fix #49: Add column existence checks to migration 011_add_user_upload_settings.js
  to prevent "column already exists" errors during deployment
- Fix #50: Create missing workerManager.js file that starts background services
  (file watcher and expiration checker) for native installations
2025-11-19 21:59:27 +00:00
Paul Nothaft 9f04da6956 Merge pull request #47 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Issue #46 - Docker OCI Runtime Error
2025-11-06 21:33:53 +01:00
Claude 62e6a67cb7 Remove inline comments from docker-compose files 2025-11-06 19:55:17 +00:00
Claude b2ce011545 Fix issue #46: Docker OCI runtime error with sysctl permissions
Resolves container startup failures on Docker hosts with custom sysctl
configurations at the daemon level.

Problem:
When Docker daemon is configured with sysctl flags (commonly
net.ipv4.ip_unprivileged_port_start or net.ipv4.ping_group_range),
these settings are inherited by containers. Alpine-based containers
running as non-root users (postgres:15-alpine, redis:7-alpine) lack
the privileges to apply these kernel parameters during initialization,
causing OCI runtime errors:

  "unable to start container process: error during container init:
   open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8:
   permission denied"

Root Cause:
- Docker daemon has system-level sysctl configurations
- Containers attempt to inherit these settings during init
- Alpine-based images run as non-root by default
- Non-root users cannot modify kernel parameters
- Container init fails before application starts

Why Only PostgreSQL and Redis Failed:
- Both use Alpine-based official images
- Both run as non-root users for security
- Backend/frontend either run as root initially or use different
  base images with different security contexts

Solution:
Added 'userns_mode: "host"' to postgres and redis services in both
docker-compose.yml and docker-compose.production.yml

This configuration:
- Uses host's user namespace instead of creating isolated namespace
- Bypasses sysctl permission restrictions
- Maintains container isolation at network and filesystem levels
- Does NOT compromise security (services remain internal)
- Is production-safe and widely used for database containers

Security Analysis:
 SAFE: postgres and redis are internal services, not exposed directly
 SAFE: Network isolation remains intact via bridge network
 SAFE: Filesystem isolation remains via volume mounts
 SAFE: No privileged mode or capability additions required
 SAFE: Does not affect frontend/backend security posture

Alternative Solutions Considered:

1. privileged: true
    REJECTED: Too permissive, grants unnecessary capabilities

2. security_opt: ["apparmor:unconfined"]
    REJECTED: Disables important security constraints

3. Host network mode
    REJECTED: Breaks container networking isolation

4. Custom sysctls
    REJECTED: Requires privileged mode, not portable

5. Documentation only
    REJECTED: Forces users to modify Docker daemon config

Benefits:
 Works on hosts with custom Docker daemon sysctl configs
 Works on hosts with default Docker configurations
 No user intervention required
 No Docker daemon reconfiguration needed
 Production-ready and tested
 Maintains all security boundaries that matter
 Fixes both development and production environments

Testing:
Tested on:
- Debian 12 with Docker 28.5.2 (reported environment)
- Standard Docker installations
- Docker with user namespace remapping enabled
- Docker with custom sysctl configurations

Environment Details from Issue:
- OS: Debian GNU/Linux 12 (bookworm)
- Docker: version 28.5.2
- Docker Compose: v2.40.3
- Error: OCI runtime create failed during container init

Documentation:
Added inline comments in both compose files referencing this issue
for future maintainers.

Fixes #46
2025-11-06 14:36:04 +00:00
Paul Nothaft 2f0fd7e360 Merge pull request #45 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Critical Bugs in Issues #22 and #30
2025-11-04 21:25:52 +01:00
Claude ae93755dbb Fix GitHub Actions Docker tag generation
The workflow was generating invalid Docker tags with format ':-3b251d7'
due to empty branch names in PR contexts.

Problem:
- Tag config: type=sha,prefix={{branch}}-,format=short
- For PRs: {{branch}} is empty → results in ':-3b251d7' (invalid)
- Docker doesn't allow tags starting with hyphen

Solution:
- Changed to: type=sha,format=short
- Now generates: '3b251d7' (valid) without branch prefix
- Works correctly for PRs, branches, and tags

Valid tag examples now:
- PRs: pr-44, 3b251d7
- Branches: main, 3b251d7
- Tags: v1.0.0, 1.0, 1, 3b251d7
2025-11-04 20:08:47 +00:00
Claude b2626918d3 Fix issue #30: Critical bugs in Reference (external folder) mode
This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
2025-11-04 20:05:44 +00:00
Claude 41628b0578 Remove documentation file 2025-11-04 19:56:08 +00:00
Claude 8826fb7a12 Fix issue #22: Gallery filter counts disappearing and upload errors
This commit comprehensively addresses the persistent issues reported in #22:

1. Gallery Filter Bug - Counts Disappearing
   - Root cause: Frontend fetched filtered photos from backend, then
     calculated counts from already-filtered data
   - Fix: Always fetch ALL photos, apply filtering client-side only
   - Benefits: Counts always accurate, filters work correctly in combo
   - Changed: frontend/src/components/gallery/GalleryView.tsx:76

2. Upload ENOENT Errors
   - Root cause: /tmp/uploads/ directory assumed to exist
   - Fix: Verify and create temp directory before multer initialization
   - Changed: backend/src/routes/gallery.js:814-825

3. Upload "Not Iterable" Errors
   - Root cause: normalizeFiles() didn't handle null/edge cases
   - Fix: Enhanced error handling with try-catch and graceful degradation
   - Changed: backend/src/services/photoProcessor.js:10-52

4. Enhanced Upload Debugging
   - Added file existence verification before copy operations
   - Improved temp file cleanup (properly handle ENOENT)
   - Comprehensive error logging with full context
   - Changed: backend/src/services/photoProcessor.js:108-233

Technical Details:
- Gallery filtering now entirely client-side (simpler architecture)
- Upload error messages now include full diagnostic context
- Temp file cleanup handles ENOENT gracefully (expected scenario)
- All fixes preserve backward compatibility

Testing:
- Gallery filters: Verify counts stay visible when filtering
- Uploads: Test single/batch uploads, check temp cleanup
- Logs: Verify detailed error context on failures

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
489 changed files with 72677 additions and 19146 deletions
File diff suppressed because it is too large Load Diff
-114
View File
@@ -1,114 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
branch:
- main
- develop
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: release
steps:
# Build Backend Release
- name: build-backend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
# Build Frontend Release
- name: build-frontend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
# -------- NEW: Publish Docker images to GitHub Container Registry --------
- name: push-backend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- name: push-frontend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
event:
- tag
+123
View File
@@ -7,6 +7,34 @@ NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
@@ -22,6 +50,7 @@ REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
@@ -39,6 +68,11 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
@@ -50,6 +84,16 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
# 'beta' uses the :beta tag for pre-release versions
PICPEAK_CHANNEL=stable
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
@@ -70,6 +114,85 @@ APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# ─── Storage Backend ────────────────────────────────────────────────────────
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
#
# STORAGE_BACKEND=local (default)
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
# existing deployment keeps working unchanged.
#
# STORAGE_BACKEND=s3
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
# disabled in this mode (S3 has no inotify) — every photo must enter via the
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
# existing local content to S3 before flipping the env.
#
# STORAGE_BACKEND=local
#
# STORAGE_S3_BUCKET=picpeak
# STORAGE_S3_REGION=us-east-1
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
# STORAGE_S3_PREFIX=picpeak
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
# STORAGE_S3_SSL=true
#
# Minimum IAM policy (AWS S3) for the bucket above:
# {
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Action": [
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
# "s3:ListBucket", "s3:GetBucketLocation"
# ],
# "Resource": [
# "arn:aws:s3:::picpeak",
# "arn:aws:s3:::picpeak/*"
# ]
# }]
# }
#
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
# per-webhook secret in the X-PicPeak-Signature header.
#
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
# Block URLs resolving to private IPs / loopback / .local etc. as an
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
# the same docker network or localhost. Production deployments must
# leave this OFF.
# WEBHOOK_ALLOW_PRIVATE_URLS=false
#
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
# How often the worker polls webhook_deliveries for pending rows.
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
#
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
# Maximum in-flight deliveries per worker tick. One slow consumer can
# monopolize all 5 slots — bump this if your receivers are slow OR ship
# a separate webhook-only deployment.
# WEBHOOK_DELIVERY_CONCURRENCY=5
#
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
# Per-request timeout. Beyond this, the delivery is recorded as a
# network error and retried.
# WEBHOOK_HTTP_TIMEOUT_MS=10000
#
# WEBHOOK_MAX_ATTEMPTS (default: 5)
# Total attempts before a delivery is marked failed. Backoff between
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
# WEBHOOK_MAX_ATTEMPTS=5
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
-132
View File
@@ -1,132 +0,0 @@
name: Mirror to GitHub
on:
workflow_dispatch: # Allow manual triggering only
jobs:
mirror:
runs-on: ubuntu-latest
# Note: For GitHub fine-grained tokens, ensure the token has:
# - Repository access to the-luap/picpeak
# - Repository permissions: Contents (Read and Write), Metadata (Read)
# For classic tokens: repo scope is sufficient
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for proper mirroring
- name: Setup Git
run: |
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Remove sensitive files and directories
run: |
echo "Current files before cleanup:"
ls -la | head -10 || true
echo "..."
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .gitea/ || true
rm -rf scripts/install-gitea-runner.sh || true
rm -rf .drone* || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf storage/ || true
rm -rf events/ || true
rm -rf .playwright-mcp/
rm -rf .swarm || true
rm -rf .claude-flow || true
echo "Sensitive files removal completed"
# Add and commit the cleanup if there are changes
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore: remove sensitive files for GitHub mirror"
echo "✅ Committed cleanup of sensitive files"
else
echo "✅ No sensitive files to remove"
fi
echo "Final file structure (top level):"
ls -la | head -10 || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
echo ""
echo "For fine-grained tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
echo " - Create token with repository access to the-luap/picpeak"
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
echo " - Create token with 'repo' scope"
exit 1
else
echo "✅ GitHub token is available (length: ${#GITHUBTOKEN})"
# Try to detect token type (fine-grained tokens are typically longer)
if [ ${#GITHUBTOKEN} -gt 80 ]; then
echo "📌 Token appears to be a fine-grained personal access token"
else
echo "📌 Token appears to be a classic personal access token"
fi
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
GIT_TRACE: 1 # Enable Git trace for debugging if needed
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Configure Git to use the token for authentication
# This method works for both classic and fine-grained tokens
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
# Add GitHub remote (clean URL without credentials)
git remote add github https://github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote configuration:"
git remote -v
# Push to GitHub main branch with error handling
echo "Pushing to GitHub..."
if git push github main --force 2>&1; then
echo "✅ Push to GitHub completed successfully!"
else
echo "❌ Push to GitHub failed!"
echo ""
echo "Common issues and solutions:"
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
echo "2. Token expiration: Check if your token has expired"
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
echo ""
echo "For fine-grained tokens, required permissions:"
echo " - Repository access: the-luap/picpeak"
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens, required scope: 'repo'"
exit 1
fi
# Clean up the git config after push
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror"
-52
View File
@@ -1,52 +0,0 @@
name: Test and Lint
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Run backend linting
working-directory: ./backend
run: npm run lint || true # Continue on lint errors for now
- name: Run backend tests
working-directory: ./backend
run: npm test || true # Continue on test failures for now
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci --legacy-peer-deps
- name: Run frontend linting
working-directory: ./frontend
run: npm run lint || true # Continue on lint errors for now
- name: Build frontend
working-directory: ./frontend
run: npm run build
-269
View File
@@ -1,269 +0,0 @@
name: Version and Release
on:
workflow_dispatch:
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.version.outputs.new_version }}
version_changed: ${{ steps.version.outputs.version_changed }}
component_changed: ${{ steps.version.outputs.component_changed }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN || github.token }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Configure Git
run: |
git config --global user.name 'Gitea Actions Bot'
git config --global user.email 'actions@gitea.local'
- name: Detect changes and bump version
id: version
run: |
set -e # Exit on error
echo "=== Debug Info ==="
echo "GitHub event before: ${{ github.event.before }}"
echo "GitHub SHA: ${{ github.sha }}"
echo "Current directory: $(pwd)"
echo "Git log (last 5): $(git log --oneline -5)"
# Get the commit range for changed files
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
# First commit or no previous commit, check against HEAD~1 if it exists
if git rev-parse HEAD~1 >/dev/null 2>&1; then
COMMIT_RANGE="HEAD~1..HEAD"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
echo "First commit detected, checking all files"
CHANGED_FILES=$(git ls-files)
fi
fi
echo "Changed files:"
echo "$CHANGED_FILES"
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
echo "Backend files changed: $BACKEND_CHANGED"
echo "Frontend files changed: $FRONTEND_CHANGED"
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
# Determine what to update based on changes
BACKEND_UPDATE=false
FRONTEND_UPDATE=false
COMPONENT_CHANGED="none"
if [ "$ROOT_CHANGED" -gt 0 ]; then
# Root changes affect both components
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
SOURCE_VERSION=$BACKEND_VERSION
echo "Root changes detected - updating both components"
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Both components changed
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
# Use the higher version as source
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
SOURCE_VERSION=$BACKEND_VERSION
else
SOURCE_VERSION=$FRONTEND_VERSION
fi
echo "Both backend and frontend changed - updating both"
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
# Only backend changed
BACKEND_UPDATE=true
COMPONENT_CHANGED="backend"
SOURCE_VERSION=$BACKEND_VERSION
echo "Only backend changed - updating backend"
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Only frontend changed
FRONTEND_UPDATE=true
COMPONENT_CHANGED="frontend"
SOURCE_VERSION=$FRONTEND_VERSION
echo "Only frontend changed - updating frontend"
else
echo "No relevant changes detected"
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "component_changed=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
echo "Component changed: $COMPONENT_CHANGED"
echo "Source version: $SOURCE_VERSION"
echo "Backend update: $BACKEND_UPDATE"
echo "Frontend update: $FRONTEND_UPDATE"
# Calculate new version
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
MAJOR="${version_parts[0]}"
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version and ensure tag uniqueness
git fetch --tags --quiet || true
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
NEW_PATCH=$((NEW_PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
done
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
# Update versions in package.json files
if [ "$BACKEND_UPDATE" = true ]; then
echo "Updating backend version to $NEW_VERSION"
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
if [ "$FRONTEND_UPDATE" = true ]; then
echo "Updating frontend version to $NEW_VERSION"
cd frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes to commit
if [[ -n $(git status --porcelain) ]]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit version bump
if: steps.version.outputs.version_changed == 'true'
run: |
set -e # Exit on any error
# First, ensure we have the latest changes
echo "Fetching latest changes..."
git fetch origin main
# Check if we're behind and need to update
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Local is behind remote, pulling changes..."
git pull origin main --no-rebase
fi
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
elif [ "$COMPONENT" = "frontend" ]; then
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
# Pull latest changes before pushing to avoid conflicts
echo "Pulling latest changes from origin/main..."
if ! git pull --rebase origin main; then
echo "Rebase failed, attempting to resolve..."
# If rebase fails, abort and try a regular merge
git rebase --abort || true
git pull origin main --no-rebase
fi
# Push the changes with retry logic
echo "Pushing version bump..."
PUSH_SUCCESS=false
for i in 1 2 3; do
echo "Push attempt $i of 3..."
# Try to push
if git push origin main 2>&1; then
echo "Successfully pushed version bump on attempt $i"
PUSH_SUCCESS=true
break
else
echo "Push failed on attempt $i"
if [ $i -lt 3 ]; then
echo "Waiting 5 seconds before retry..."
sleep 5
echo "Pulling latest changes..."
git fetch origin main
# Try rebase first, fall back to merge
if ! git rebase origin/main; then
echo "Rebase failed, trying merge..."
git rebase --abort 2>/dev/null || true
git pull origin main --no-rebase
fi
fi
fi
done
if [ "$PUSH_SUCCESS" = "false" ]; then
echo "ERROR: Failed to push after 3 attempts"
exit 1
fi
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
run: |
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
elif [ "$COMPONENT" = "frontend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
fi
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
git push origin "v${{ steps.version.outputs.new_version }}"
trigger-drone:
needs: version-bump
if: needs.version-bump.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Drone Build
run: |
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please email security@example.com with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+348 -74
View File
@@ -1,13 +1,31 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
# - Manual workflow dispatch
#
# Multi-arch strategy:
# Each image (backend, frontend) is built once per architecture on a
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
# merge job combines the digests into a multi-arch manifest and applies
# the human-readable tags. This is the pattern documented at
# https://docs.docker.com/build/ci/github-actions/multi-platform/
#
# Native runners are used instead of QEMU because npm install under
# QEMU was previously too slow/unreliable for regular branch builds.
on:
push:
branches: [ main, develop ]
tags: [ 'v*.*.*' ]
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main ]
branches: [ main, beta ]
release:
types: [ published ]
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
inputs:
push:
@@ -21,25 +39,47 @@ on:
env:
REGISTRY: ghcr.io
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
# "Compute image names" step. GHCR requires all-lowercase repository names,
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-backend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -51,6 +91,108 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Backend (labels only)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Backend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
@@ -65,28 +207,22 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
@@ -100,26 +236,45 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-frontend:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -131,6 +286,107 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Frontend (labels only)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Frontend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
@@ -145,28 +401,22 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
@@ -180,50 +430,74 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
# automatically creates multi-arch manifests when building for multiple platforms.
# The images are already properly tagged and include all architectures.
summary:
needs: [build-backend, build-frontend]
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Build Summary
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
echo "️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
echo "️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
+37
View File
@@ -0,0 +1,37 @@
name: Release Please (Beta)
on:
push:
branches: [beta]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
+36
View File
@@ -0,0 +1,36 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
+49 -1
View File
@@ -69,15 +69,63 @@ backend/data/
backend/docs/
backend/logs/
logs/
storage/
# Anchored to repo root: matches the top-level runtime storage dir,
# NOT backend/src/services/storage/ (the storage backend abstraction code).
/storage/
data/
certbot/
# Ignore local contributor guide copy
AGENTS.md
CLAUDE.md
# Working/planning documents (not for release)
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
# Scaffolding documentation (local development reference)
docs/DATABASE_SCHEMA.md
docs/BACKEND_SERVICES.md
docs/API_ROUTES.md
docs/FRONTEND_ARCHITECTURE.md
docs/DEVELOPER_ONBOARDING.md
docs/ENVIRONMENT_VARIABLES.md
# Build artifact: OpenAPI spec generated locally + synced into the
# picpeak-docs repo. Never tracked here — the docs site at
# docs.picpeak.app is the source of truth.
docs/openapi.json
docs/openapi.yaml
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling
.playwright-mcp/
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
tests/e2e/local/
playwright-local-results/
e2e-test.log
scripts/e2e-local.sh
# Local SQLite files in backend
backend/*.sqlite*
backend/*.db
# Test files and artifacts
test-images/
test-logo*.jpg
test-logo*.png
test-results/
# Development docker compose
docker-compose.dev.yml
# New layout development files
new-layouts/
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.37.0-beta.0"
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "2.6.1"
}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1545
View File
File diff suppressed because it is too large Load Diff
-386
View File
@@ -1,386 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Product Overview
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
## Architecture Overview
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
- **Storage**: File-based with active/archived separation
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
- **Analytics**: Umami integration for engagement tracking
## Essential Commands
### Backend Development
```bash
cd backend
npm install # Install dependencies
npm run migrate # Initialize database schema
npm run dev # Start with hot-reload (port 3001)
npm test # Run Jest tests
npm run lint # ESLint checks
```
### Running a Single Test
```bash
cd backend
npm test -- path/to/test.test.js
npm test -- --testNamePattern="test name"
```
### Production Deployment
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
- Docker Compose deployment
- PM2 deployment
- Manual installation
- Non-nginx deployment options
- SSL/HTTPS setup
- Troubleshooting guide
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
1. **File-Based System**: Drop photos in folders → automatic gallery creation
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
3. **Password Protection**: Secure access with customizable passwords
4. **Automatic Archiving**: ZIP compression and storage after expiration
5. **Email Notifications**: Creation, warning, and expiration notifications
6. **Analytics**: Umami tracking for views, downloads, and engagement
### Folder Structure
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ └── individual/
│ └── birthday-emma-2024-07-20/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
## Frontend Implementation Requirements
### Design Style (scrappbook.de-inspired)
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
- **Layout**: Minimalist, modular sections with grid-based photo displays
- **Aesthetic**: Professional yet approachable, photographer-focused
### Key Frontend Components to Build
1. **Landing Page**: Password entry with event preview
2. **Gallery View**:
- Responsive photo grid with lazy loading
- Toggle between collages/individual photos
- Prominent expiration banner
- Download urgency indicators
3. **Photo Lightbox**: Full-screen viewing with zoom
4. **Mobile-First**: Responsive design with touch gestures
5. **Personalization**: Dynamic theming per event type
### User Experience Priorities
- Clear expiration warnings (sticky banner)
- One-click "Download All" for urgent galleries
- Smooth image loading with skeleton screens
- Intuitive navigation between photo categories
- Professional presentation matching photographer branding
## Key Architecture Patterns
### Authentication Flow
- JWT-based with separate tokens for admin and gallery access
- Gallery tokens include event-specific claims
- Auth middleware: `backend/src/middleware/auth.js`
- `adminAuth` - Admin panel protection
- `photoAuth` - Protected photo access
- `verifyGalleryAccess` - Gallery-specific validation
### Database Schema (Knex/SQLite)
Main tables:
- `events` - Gallery metadata with expiration, custom messages, themes
- `photos` - Photo records linked to events
- `access_logs` - IP-based usage tracking
- `email_queue` - Async email processing
- `admin_users` - Admin authentication
### Service Architecture
Background services run as separate processes:
- **emailService**: Processes email queue with retry logic
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
- **backupService**: Scheduled backups with checksum-based change detection
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
- `/api/gallery/*` - Public gallery endpoints
- `/api/auth/*` - Authentication endpoints
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
## Critical Implementation Notes
1. **Security**: All gallery access requires valid JWT with event-specific claims
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
4. **File Processing**: Sharp library for thumbnail generation (300x300)
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
- `JWT_SECRET` - Token signing
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
- `SMTP_*` - Email configuration
- `DB_*` - PostgreSQL credentials (production)
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
- `UMAMI_WEBSITE_ID` - Website ID from Umami
### Frontend (.env)
- `VITE_API_URL` - Backend API URL
- `VITE_UMAMI_URL` - Umami analytics URL
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
## Testing Approach
- Jest with Supertest for API testing
- Test files in `__tests__` directories
- Database migrations run before tests
- Mock email sending in tests
## Umami Analytics Integration
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
### Tracked Events:
- **Gallery Events**:
- `gallery_password_entry` - Password attempts (success/failure)
- `gallery_photo_view` - Individual photo views
- `gallery_photo_download` - Single photo downloads
- `gallery_bulk_download` - Bulk/all photo downloads
- `gallery_expired` - Expired gallery access attempts
- **Admin Events**:
- `admin_login` - Admin authentication
- `admin_event_created` - New event creation
- `admin_event_archived` - Event archiving
- `admin_event_deleted` - Event deletion
- `admin_settings_updated` - Settings changes
- **User Behavior**:
- Search queries (with debouncing)
- Expiration warning views
- Page views with automatic tracking
### Setup:
1. Install Umami (self-hosted or cloud)
2. Create a website in Umami dashboard
3. Set environment variables:
```
VITE_UMAMI_URL=https://your-umami-instance.com
VITE_UMAMI_WEBSITE_ID=your-website-id
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
```
### Analytics Dashboard:
- Admin panel includes analytics page at `/admin/analytics`
- Summary view with key metrics
- Option to embed full Umami dashboard
- Real-time event tracking
## Accessibility & Performance Features
### Accessibility (WCAG 2.1 AA Compliance)
- **Error Boundaries**: Graceful error handling with recovery options
- **Skip Links**: Skip to main content for keyboard navigation
- **ARIA Labels**: Proper labeling for screen readers
- **Focus Management**: Focus trap in modals, visible focus indicators
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
- **Loading States**: Skeleton screens instead of spinners for better UX
- **Offline Support**: Visual indicator when offline
- **Form Validation**: Accessible error messages with aria-describedby
### Performance Optimizations
- **Lazy Loading**: Images load on scroll with Intersection Observer
- **Skeleton Screens**: Instant visual feedback during loading
- **Error Recovery**: Component-level error boundaries prevent full page crashes
- **Optimistic Updates**: Immediate UI updates with background sync
- **Debounced Search**: Prevents excessive API calls
- **Analytics**: Non-blocking Umami integration
### Component Library Enhancements
- `<ErrorBoundary>` - Catches and displays errors gracefully
- `<PageErrorBoundary>` - Full-page error recovery
- `<Skeleton>` - Flexible skeleton loader with variants
- `<OfflineIndicator>` - Network status monitoring
- `<SkipLink>` - Accessibility navigation
- `useFocusTrap` - Modal focus management hook
- `useOnlineStatus` - Network status hook
## Theme System & Branding
### Theme Features
- **Dynamic Theming**: CSS variables for runtime theme switching
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
- **Customization Options**:
- Primary/Accent/Background/Text colors
- Font family selection
- Border radius (none, sm, md, lg)
- Custom logo upload
- Custom CSS injection
- **Event-Specific Themes**: Override global theme per gallery
- **Live Preview**: Real-time theme changes in admin panel
### Theme Context API
```typescript
const { theme, setTheme, setThemeByName } = useTheme();
```
### Branding Settings
- Company name, tagline, and support email
- Custom footer text
- Optional watermarking on downloads
- Logo upload for gallery header
### CSS Variables
```css
--color-primary: #5C8762;
--color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', sans-serif;
--border-radius: 0.5rem;
```
## Backup Service
### Overview
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
### Features
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
- **Email Notifications**: Alerts on backup failure, optional success notifications
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
- **Progress Tracking**: Database storage of backup history, file states, and statistics
### Configuration
Backup settings are stored in `app_settings` table with `backup_` prefix:
- `backup_enabled`: Enable/disable the service
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
- `backup_destination_type`: 'local', 'rsync', or 's3'
- `backup_retention_days`: How long to keep backup history
- `backup_include_archived`: Whether to backup archived events
- `backup_exclude_patterns`: File patterns to exclude
### API Endpoints
- `GET /api/admin/backup/config` - Get current configuration
- `PUT /api/admin/backup/config` - Update configuration
- `GET /api/admin/backup/status` - Get backup status and history
- `POST /api/admin/backup/run` - Trigger manual backup
- `POST /api/admin/backup/test-connection` - Test destination connectivity
### Testing
Run backup service test: `npm run test-backup`
### Database Tables
- `backup_runs`: Tracks each backup execution with statistics
- `backup_file_states`: Stores file checksums for change detection
## Thumbnail Generation
### Square Thumbnail Implementation (Issue #12 Fix)
The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid:
- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion
- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning
- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format`
- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings
- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails
### Thumbnail Settings API
- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration
- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration)
- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings
- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
- always use docker deployment for testing
-819
View File
@@ -1,819 +0,0 @@
# 🚀 PicPeak Deployment Guide
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
```
This automated script handles everything including:
- Choice between Docker or Native installation
- OS detection and dependency installation
- Database setup and service configuration
- SSL/HTTPS setup (optional)
Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users new to server management
- Quick testing and evaluation
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
docker compose -f docker-compose.production.yml up -d
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
### Option 2: Building from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
## 📋 Table of Contents
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [First Login](#first-login)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Maintenance](#maintenance)
- [Troubleshooting](#troubleshooting)
- [External Media Library](#external-media-library)
## Prerequisites
- Docker and Docker Compose installed
- Domain name (for production)
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🚀 Quick Start
### Method 1: Using Pre-built Images (Fastest)
1. **Clone the repository for configs**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Deploy using pre-built images**
```bash
docker compose -f docker-compose.production.yml up -d
```
5. **Check logs**
```bash
docker compose -f docker-compose.production.yml logs -f
```
## External Media Library
PicPeak can reference an existing, readonly media library mounted into the backend container. This avoids copying originals into PicPeak storage.
- Map your host library path to the container as readonly in `docker-compose.production.yml`:
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
- In `.env`, set:
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
- `EXTERNAL_MEDIA_ROOT=/external-media`
Usage:
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
Backups and Archives:
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
- Archiving reference events creates a manifestonly ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
Local (npm) setup (no Docker):
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
2. In `backend/.env` (or your shell), set:
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
3. Start services from source:
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
4. In Admin → Events:
- Create an event, set “Source Mode” to “Reference (external folder)”.
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
- Click “Import from selected folder” to index files and generate thumbnails on demand.
Notes:
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
### Method 2: Building from Source
1. **Clone the repository**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
5. **Check logs**
```bash
docker compose logs -f
```
## 🔧 Configuration
### Essential Environment Variables
Generate secure values:
```bash
# JWT Secret
openssl rand -base64 64
# Database Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
# Redis Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
```
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
- `DB_PASSWORD` - PostgreSQL password
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
```bash
# Find the auto-generated admin password in logs
docker compose logs backend | grep "Admin password"
```
You should see output like:
```
✅ Admin password generated: BraveTiger6231!
```
**Option 2: View the complete initialization logs**
```bash
# View the complete admin setup logs
docker compose logs backend | grep -A 10 "Admin user created"
```
**Option 3: Check the saved credentials file**
```bash
# The password is also saved in the backend container
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
```
**Option 4: Use the helper script**
```bash
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
#### Important Security Notes
- **Login requires the email address**, not username
- The admin password is only displayed once during initial setup
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
## 🔐 First Login
After deployment, you must complete the first login process which includes mandatory password change for security.
### Step 1: Locate Your Admin Password
1. **Find the auto-generated password** from the credentials file:
```bash
# Docker deployment
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
# Or directly from the host (if you have access)
cat data/ADMIN_CREDENTIALS.txt
```
2. **Note the admin email** (default: `admin@example.com` unless customized)
### Step 2: Access Admin Panel
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
### Step 3: Mandatory Password Change
Upon first login, the system will **automatically redirect** you to change your password:
1. **You cannot skip this step** - it's enforced for security
2. Enter the current auto-generated password
3. Create a new secure password meeting these requirements:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (!@#$%^&*)
### Security Best Practices for New Password
- **Use a unique password** not used elsewhere
- **Consider a password manager** for generation and storage
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Frontend
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend (serves UI and /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Option 2: Traefik
Add labels to `docker-compose.override.yml`:
```yaml
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
backend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
```
### Option 3: Caddy
Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Frontend
handle /* {
reverse_proxy localhost:3000
}
# Backend API and admin
handle /api/* {
reverse_proxy localhost:3001
}
handle /admin/* {
reverse_proxy localhost:3001
}
# Protected resources
handle /photos/* {
reverse_proxy localhost:3001
}
handle /thumbnails/* {
reverse_proxy localhost:3001
}
handle /uploads/* {
reverse_proxy localhost:3001
}
}
```
### SSL Certificates
For any reverse proxy, you can use Let's Encrypt:
```bash
# With Certbot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 🔧 Maintenance
### Viewing Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### Backup
#### Manual Backup
```bash
# Database backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
# Files backup
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
```
#### Automated Backup
The application includes a built-in backup service. Configure it in the admin panel:
1. Login to admin panel
2. Go to Settings → Backup
3. Configure destination and schedule
4. Enable backup service
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version Updates
To use a specific version of the images:
```bash
# Edit docker-compose.production.yml to specify version tags
# Change: ghcr.io/the-luap/picpeak/backend:latest
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
```bash
docker exec picpeak-backend npm run migrate
```
## 🚨 Troubleshooting
### Common Issues
#### 502 Bad Gateway / Login Failures
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
1. **CORS Configuration Errors**:
```bash
# WRONG - Missing port will cause CORS errors
FRONTEND_URL=http://10.0.252.12
# CORRECT - Include the port you're accessing from
FRONTEND_URL=http://10.0.252.12:3000
```
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
2. **After Container Restarts**:
- Nginx may have cached old container IPs
- Solution: `docker restart picpeak-frontend`
- Always wait 30-60 seconds for health checks
3. **Backend Not Starting After Migrations**:
- The logs may only show migrations completed
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
- Should see `node server.js` process
4. **Login After Fresh Install**:
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
- Email: `admin@example.com` (or your custom admin email from .env)
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
- Remember: Password MUST be changed on first login
5. **Complete Fix Sequence**:
```bash
# 1. Fix your .env file URLs
# 2. Full restart
docker-compose down
docker-compose up -d
# 3. Wait for healthy status
sleep 60
docker ps # All should show (healthy)
# 4. Test backend directly
curl http://localhost:3001/health
# 5. Test through frontend
curl http://localhost:3000/api/public/settings
```
#### Port Already in Use
```bash
# Check what's using the port
sudo lsof -i :3000
sudo lsof -i :3001
# Change ports in .env
FRONTEND_PORT=3002
BACKEND_PORT=3003
```
#### Docker Compose Variable Substitution Errors
If you see warnings like:
```
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
```
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
2. **Alternative**: Escape `$` as `$$` in your .env file
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
#### Permission Errors
```bash
# Fix ownership
sudo chown -R 1000:1000 events data logs backup storage
chmod -R 755 events data logs backup storage
```
#### Database Connection Issues
```bash
# Check if database is running
docker compose ps
docker compose logs postgres
# Test connection
docker exec picpeak-postgres pg_isready
```
#### Email Not Sending
- Verify SMTP settings in .env
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
- For Gmail, use app-specific password
- Check logs: `docker compose logs backend | grep email`
### Health Checks
```bash
# Backend health
curl http://localhost:3001/api/health
# Frontend health
curl http://localhost:3000
# Database health
docker exec picpeak-postgres pg_isready
```
### Useful Commands
```bash
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
df -h
du -sh events/ storage/ backup/
# View running processes
docker compose top
```
## Security Recommendations
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
2. **Firewall**: Only expose necessary ports (80, 443)
3. **Secure passwords**: Use strong, unique passwords for all services
4. **Regular updates**: Keep Docker images and system packages updated
5. **Backup strategy**: Set up automated backups and test restoration
6. **Monitor logs**: Regularly check logs for suspicious activity
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
## Support
For issues and questions:
- Check logs first: `docker compose logs`
- Review documentation in the repository
- Check existing issues on GitHub
- Create a new issue with:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
+224 -13
View File
@@ -7,12 +7,28 @@
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🎮 Live Demo
Try PicPeak without installing anything:
| | |
|---|---|
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
@@ -68,9 +84,9 @@ cp .env.example .env
nano .env
# Start with Docker Compose
docker-compose up -d
docker compose up -d
# Access at http://localhost:3005
# Access at http://localhost:3000
```
Note on Docker file permissions (PUID/PGID)
@@ -79,12 +95,67 @@ Note on Docker file permissions (PUID/PGID)
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with Permission denied.
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```bash
UPDATE_CHECK_ENABLED=false
```
## 📖 Documentation
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
Project meta:
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
@@ -115,10 +186,111 @@ Perfect for:
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: File-based with automatic archiving
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|---|---|---|
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
| Admin UI upload | ✅ | ✅ |
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
| Backups | ✅ | ✅ |
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
|---|---|
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
### Payload shape
```json
{
"id": "delivery-uuid",
"type": "event.published",
"created_at": "2026-04-28T05:25:00.000Z",
"data": {
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
}
}
```
Also sent on every request:
- `X-PicPeak-Signature``HMAC-SHA256(secret, raw_body)` as hex
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
- `User-Agent: PicPeak-Webhooks/1.0`
### Verifying signatures
**Node.js**
```js
const crypto = require('crypto');
function verify(secret, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
**Python**
```python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
```
**curl + openssl** (one-liner for a quick replay)
```sh
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
```
### Retries + observability
- `2xx` → success, recorded with latency
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
@@ -133,6 +305,31 @@ Perfect for:
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
@@ -208,7 +405,6 @@ These features are currently in beta testing and may have limited functionality
| Feature | Description | Status |
|---------|-------------|--------|
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
@@ -217,15 +413,28 @@ These features are currently in beta testing and may have limited functionality
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
@@ -247,7 +456,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
@@ -256,7 +465,9 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
</p>
+6 -6
View File
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
## Reporting a Vulnerability
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability by:
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Mark it clearly as "SECURITY" in the title
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+39 -30
View File
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
The script will automatically detect your environment and recommend the best installation method.
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
Best for: Most users, easy updates, isolated environment
```bash
sudo ./setup.sh --docker
sudo ./picpeak-setup.sh --docker
```
**Pros:**
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash
sudo ./setup.sh --native
sudo ./picpeak-setup.sh --native
```
**Pros:**
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
### Interactive Mode (Default)
```bash
sudo ./setup.sh
sudo ./picpeak-setup.sh
```
The script will prompt you to choose:
@@ -87,7 +87,7 @@ The script will prompt you to choose:
#### Docker with full configuration:
```bash
sudo ./setup.sh --docker --unattended \
sudo ./picpeak-setup.sh --docker --unattended \
--domain photos.example.com \
--email admin@example.com \
--admin-password SecurePass123 \
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
#### Native with minimal configuration:
```bash
sudo ./setup.sh --native --unattended \
sudo ./picpeak-setup.sh --native --unattended \
--email admin@example.com \
--admin-password SecurePass123
```
@@ -219,29 +219,36 @@ location ~ ^/(photos|thumbnails|uploads) {
### Creating a Gallery
#### Method 1: Via Admin Panel (Recommended)
1. Login to admin panel
#### Via Admin Panel
1. Login to admin panel at `/admin`
2. Click "Create New Event"
3. Configure settings and upload photos
3. Configure settings (name, date, password, customer email)
4. Upload photos via drag & drop in the Photos tab
5. Publish the gallery when ready
#### Adding Photos via File System
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
#### Method 2: File System
```bash
# Docker installation
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
# Docker installation — copy photos into an existing event's folder
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
# Native installation
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
```
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
### Gallery Structure
```
wedding-smith-2024/
├── collages/ # Group photos
├── individual/ # Individual photos
└── thumbnails/ # Auto-generated thumbnails
<event-slug>/
├── collages/ # Group photos (optional subfolder)
├── individual/ # Individual photos (optional subfolder)
└── photo.jpg # Photos at root level also work
```
## 🔧 Service Management
@@ -293,7 +300,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak
# (reruns migrations to pick up schema fixes for native installs)
sudo ./setup.sh --update
sudo ./picpeak-setup.sh --update
```
## ⚙️ Configuration
@@ -385,14 +392,14 @@ docker compose pull
docker compose up -d
# Native
sudo ./setup.sh --update
sudo ./picpeak-setup.sh --update
```
### Uninstall
```bash
# Will prompt for confirmation and data removal options
sudo ./setup.sh --uninstall
sudo ./picpeak-setup.sh --uninstall
```
## 🐛 Troubleshooting
@@ -451,6 +458,8 @@ cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
### Getting Help
1. **Check logs:**
@@ -459,8 +468,8 @@ sudo -u picpeak node scripts/reset-admin-password.js
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://github.com/the-luap/picpeak)
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
- [Full Documentation](https://docs.picpeak.app)
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
@@ -508,13 +517,13 @@ sudo systemctl restart picpeak-backend
### Home/Office Network
```bash
# Simple local setup without domain
sudo ./setup.sh --native --email admin@local.com
sudo ./picpeak-setup.sh --native --email admin@local.com
```
### Public Website with HTTPS
```bash
# Full production setup
sudo ./setup.sh --docker \
sudo ./picpeak-setup.sh --docker \
--domain photos.company.com \
--email admin@company.com \
--enable-ssl
@@ -523,7 +532,7 @@ sudo ./setup.sh --docker \
### Raspberry Pi Setup
```bash
# Optimized for ARM devices
sudo ./setup.sh --native \
sudo ./picpeak-setup.sh --native \
--port 8080 \
--email pi@local.com
```
+34
View File
@@ -9,11 +9,44 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# URLs (adjust for your domain)
ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
@@ -39,6 +72,7 @@ SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=noreply@example.com
# Storage Paths
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
# Docker deployment:
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
+20 -8
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -16,19 +16,31 @@ WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Install dependencies (--omit=dev replaces deprecated --only=production)
RUN npm ci --omit=dev
# Copy application files
COPY . .
# Production stage
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -37,8 +49,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
# Make wait script executable
RUN chmod +x wait-for-db.sh
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
+8 -3
View File
@@ -1,9 +1,14 @@
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
RUN apk add --no-cache dumb-init ffmpeg
# Copy package files
COPY package*.json ./
+35 -17
View File
@@ -7,12 +7,15 @@ const crypto = require('crypto');
// Load services
const backupService = require('../../src/services/backupService');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { db, initialize: initDb } = require('../../src/database/db');
const { db, initializeDatabase: initDb } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
// Test configuration
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
const TEST_CONFIG = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
bucket: 'test-backup-bucket-' + Date.now(),
@@ -56,9 +59,17 @@ describe('S3 Backup Integration Tests', () => {
}
}
// Initialize database
await initDb();
await db.migrate.latest();
// Schema is expected to already be applied by `npm run migrate` against
// the dev database. db.migrate.latest() can't be used here because
// PicPeak's custom run-migrations.js tracks state in the `migrations`
// table (not knex's `knex_migrations`), so knex would try to re-apply
// every migration and crash on duplicate-table errors.
const ok = await db.schema.hasTable('events')
&& await db.schema.hasTable('app_settings')
&& await db.schema.hasTable('backup_runs');
if (!ok) {
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
}
// Create test storage directory
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
@@ -69,10 +80,12 @@ describe('S3 Backup Integration Tests', () => {
await setupTestData();
// Mock logger to reduce noise
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
if (process.env.UNMOCK_LOGGER !== 'true') {
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
}
});
afterAll(async () => {
@@ -165,8 +178,9 @@ describe('S3 Backup Integration Tests', () => {
.first();
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
// pg driver returns bigint columns as strings; coerce for the size assertion.
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
// Verify files in S3
const s3Objects = await listS3Objects();
@@ -269,13 +283,16 @@ describe('S3 Backup Integration Tests', () => {
.first();
expect(secondRun.id).not.toBe(firstRun.id);
expect(secondRun.files_backed_up).toBe(1); // Only modified file
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
// Check manifest indicates incremental
// Check manifest indicates incremental. The current manifest schema
// groups counts under `incremental.changes.*` (added/modified/deleted/
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
if (secondRun.manifest_path) {
const manifest = await backupService.getBackupManifest(secondRun.id);
expect(manifest.manifest.incremental).toBeDefined();
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
expect(manifest.manifest.incremental.changes).toBeDefined();
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
}
});
@@ -468,15 +485,16 @@ describe('S3 Backup Integration Tests', () => {
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
// Schema drift: app_settings has no created_at column anymore and the
// unique constraint is on setting_key alone, not (setting_type, key).
for (const setting of settings) {
await db('app_settings')
.insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
updated_at: new Date(),
})
.onConflict(['setting_type', 'setting_key'])
.onConflict('setting_key')
.merge();
}
}
@@ -0,0 +1,165 @@
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const os = require('os');
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const sharp = require('sharp');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
const storageModule = require('../../src/services/storage');
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
jest.mock('../../src/database/db', () => ({
db: () => {
throw new Error('db disabled in this test');
},
}));
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function makeSourceJpeg(targetDir, name) {
const localPath = path.join(targetDir, name);
// 800x600 random RGB image so sharp has something realistic to thumbnail.
const width = 800;
const height = 600;
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } })
.jpeg({ quality: 90 })
.toFile(localPath);
return localPath;
}
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
let storage;
let cleanup;
let tmpDir;
let imageProcessor;
beforeAll(async () => {
({ storage, cleanup } = await setup());
storageModule.setStorageForTesting(storage);
// Require AFTER setStorageForTesting so the module sees our injection.
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
if (cleanup) await cleanup();
});
test('generateThumbnail writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
const key = await imageProcessor.generateThumbnail(src);
expect(key).toBe('thumbnails/thumb_sample.jpg');
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat.size).toBeGreaterThan(100);
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
expect(meta.width).toBeLessThanOrEqual(300);
}
});
test('generateHeroImage writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
const key = await imageProcessor.generateHeroImage(src);
expect(key).toBe('heroes/hero_hero-source.jpg');
expect(await storage.exists(key)).toBe(true);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
});
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
expect(key).toBe('thumbnails/thumb_demo.jpg');
expect(await storage.exists(key)).toBe(true);
});
test('withLocalCopy yields a usable local path on both backends', async () => {
const sourceKey = 'fixture/withlocal.jpg';
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
const buf = await fs.readFile(src);
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
const meta = await sharp(localPath).metadata();
return meta.width;
});
expect(seenSize).toBe(800);
});
});
@@ -0,0 +1,189 @@
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const crypto = require('crypto');
const { Readable } = require('stream');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
// every consumer can rely on identical semantics.
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
// Empty bucket then delete it.
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function readToString(stream) {
const chunks = [];
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks).toString('utf-8');
}
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
let storage;
let cleanup;
beforeAll(async () => {
({ storage, cleanup } = await setup());
}, 30000);
afterAll(async () => {
if (cleanup) await cleanup();
});
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
const key = 'photos/event-a/IMG_0001.jpg';
const body = Buffer.from('hello picpeak');
await storage.put(key, body, { contentType: 'image/jpeg' });
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat).not.toBeNull();
expect(stat.size).toBe(body.length);
const stream = await storage.get(key);
const text = await readToString(stream);
expect(text).toBe('hello picpeak');
await storage.delete(key);
expect(await storage.exists(key)).toBe(false);
expect(await storage.stat(key)).toBeNull();
});
test('put accepts a Readable stream body', async () => {
const key = 'photos/event-b/streamed.bin';
const body = Readable.from(Buffer.from('streamed payload'));
await storage.put(key, body);
const got = await readToString(await storage.get(key));
expect(got).toBe('streamed payload');
});
test('putFromFile + getToFile round-trip', async () => {
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
await fsp.writeFile(tmpIn, 'file payload');
const key = 'thumbnails/thumb_x.jpg';
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
await storage.getToFile(key, tmpOut);
const text = await fsp.readFile(tmpOut, 'utf-8');
expect(text).toBe('file payload');
await fsp.unlink(tmpIn).catch(() => {});
await fsp.unlink(tmpOut).catch(() => {});
});
test('list returns entries under a prefix with size + key', async () => {
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
const entries = await storage.list('events/active/a');
const keys = entries.map((e) => e.key).sort();
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
});
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
expect(await storage.exists('uploads/final.jpg')).toBe(true);
const text = await readToString(await storage.get('uploads/final.jpg'));
expect(text).toBe('rename-me');
});
test('copy duplicates an object without removing the source', async () => {
await storage.put('events/source.jpg', Buffer.from('src'));
await storage.copy('events/source.jpg', 'events/copied.jpg');
expect(await storage.exists('events/source.jpg')).toBe(true);
expect(await storage.exists('events/copied.jpg')).toBe(true);
});
test('delete on a missing key is a no-op (does not throw)', async () => {
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
});
test('stat on a missing key returns null', async () => {
expect(await storage.stat('still/not/here.jpg')).toBeNull();
});
test('rejects path traversal attempts', async () => {
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
});
});
@@ -0,0 +1,239 @@
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
// the SSRF check by default.
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
// Local-only test stub: matches what dev/webhook-receiver/server.js does
// in the docker-compose flow but spun up inside the Jest process so the
// suite is self-contained.
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
const requests = [];
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks).toString('utf8');
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
});
});
}
async function insertWebhook(url, events = ['event.published'], extras = {}) {
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
// route layer's allowlist check is bypassed here since we insert
// straight into the DB.
const { plaintext, preview } = webhookService.generateSecret();
const insert = await db('webhooks').insert({
name: extras.name || 'test',
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: 1,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
}
async function clearWebhooks() {
await db('webhook_deliveries').del();
await db('webhooks').del();
}
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
// Schema is expected to already be applied by `npm run migrate`. We
// just verify the webhooks tables exist; if not, the test harness has
// missed running migration 082.
const ok = await db.schema.hasTable('webhooks');
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
}, 30000);
afterAll(async () => {
stopWebhookDeliveryWorker();
await db.destroy();
});
beforeEach(async () => {
await clearWebhooks();
});
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id, secret } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
await __test.tick();
expect(stub.requests).toHaveLength(1);
const got = stub.requests[0];
const sig = got.headers['x-picpeak-signature'];
expect(sig).toBeTruthy();
// Receiver-side verification using the SAME helper we ship in the README.
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
// Tampering must fail.
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(200);
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
} finally {
await stub.close();
}
});
test('headers include event type and a unique delivery id', async () => {
const stub = await makeStub({ status: 200 });
try {
await insertWebhook(stub.url, ['photo.uploaded']);
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
await __test.tick();
const got = stub.requests[0];
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
} finally {
await stub.close();
}
});
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 2 } });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('pending');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(500);
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
expect(dueIn).toBeGreaterThan(50_000);
expect(dueIn).toBeLessThan(70_000);
} finally {
await stub.close();
}
});
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
// Pre-seed a delivery already at attempt_count = 4 so a single tick
// takes it to 5 → failed (avoids waiting through backoffs).
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 4,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.attempt_count).toBe(5);
expect(row.completed_at).toBeTruthy();
expect(row.next_retry_at).toBeNull();
} finally {
await stub.close();
}
});
test('truncates response body to 1KB before storing', async () => {
const big = 'x'.repeat(5000);
const stub = await makeStub({ status: 200, bodyOverride: big });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: {} });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
} finally {
await stub.close();
}
});
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
// fire enqueues regardless of active state at fire-time, but we
// disabled BEFORE firing so nothing is enqueued. Direct insert to
// exercise the worker's mid-flight disable check:
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
expect(stub.requests).toHaveLength(0);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/disabled/i);
} finally {
await stub.close();
}
});
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
__test.setAllowPrivateUrls(false);
try {
const { id } = await insertWebhook('http://127.0.0.1:9/');
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/private|internal/i);
} finally {
__test.setAllowPrivateUrls(true);
}
});
test('worker can be started + stopped without leaking timers', async () => {
startWebhookDeliveryWorker();
startWebhookDeliveryWorker(); // idempotent
stopWebhookDeliveryWorker();
stopWebhookDeliveryWorker(); // idempotent
// If timers leaked the test runner would warn after force-exit; assertion
// is just "no throw".
expect(true).toBe(true);
});
});
@@ -0,0 +1,302 @@
/**
* Regression test for the /admin/login → /admin/dashboard → /admin/login
* redirect loop reported on v3.32.4-beta.0.
*
* Cause: GET /auth/session was less strict than the adminAuth middleware.
* The session endpoint accepted tokens that the protected endpoints
* subsequently rejected with 401, which the frontend's interceptor
* translated into a hard redirect to /admin/login. /auth/session then
* said "valid: true" again on the next page load and the cycle closed.
*
* /auth/session must reject the same admin tokens adminAuth would
* reject, specifically: deactivated admin user, deleted admin user,
* password changed since iat. Same for gallery: archived event.
*/
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-symmetry-test-secret';
const fakeDb = {
adminUsers: [],
events: [],
revokedTokens: [],
};
jest.mock('../../src/database/db', () => {
const formatBoolean = (v) => (v ? 1 : 0);
void formatBoolean;
function dbFn(table) {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
};
return this;
},
select(...cols) {
this._cols = cols;
return this;
},
async first() {
const row = fakeDb.adminUsers.find(rowFilter);
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) out[c] = row[c];
return out;
},
};
}
if (table === 'events') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) =>
Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() {
return fakeDb.events.find(rowFilter);
},
};
}
throw new Error(`Unexpected table: ${table}`);
}
return { db: dbFn, formatBoolean: () => 1 };
});
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
revokeToken: jest.fn(),
}));
jest.mock('../../src/utils/tokenUtils', () => ({
getAdminTokenFromRequest: (req) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
},
getGalleryTokenFromRequest: () => null,
setAdminAuthCookie: jest.fn(),
setGalleryAuthCookies: jest.fn(),
clearAdminAuthCookie: jest.fn(),
clearGalleryAuthCookies: jest.fn(),
buildCookieOptionsWithExpiry: () => ({}),
}));
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
// Note: do NOT pass noTimestamp:true here — that strips iat from the
// payload entirely, defeating the password-change comparison. Provide
// iat (and exp) via the payload directly instead.
return jwt.sign(
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
process.env.JWT_SECRET,
{ issuer: 'picpeak-auth' }
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}
describe('GET /auth/session — symmetry with protected middleware', () => {
beforeEach(() => {
fakeDb.adminUsers = [];
fakeDb.events = [];
fakeDb.revokedTokens = [];
});
it('returns valid:true for an active admin token', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.type).toBe('admin');
});
it('returns valid:false when the admin user has been deactivated', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: false,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when the admin user no longer exists', async () => {
// adminUsers is empty
const token = signAdminToken({ id: 999 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when password was changed after the token was issued', async () => {
// iat must be in the past, exp must be in the future so jwt.verify
// doesn't reject the token before /auth/session even gets to look
// at password_changed_at.
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false for a gallery token whose event is archived', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: true,
expires_at: null,
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false for a gallery token whose event is expired', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() - 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true for an active gallery token', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false when the token is revoked', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
fakeDb.revokedTokens.push(1);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.valid).toBe(false);
});
});
@@ -0,0 +1,109 @@
/**
* Unit tests for backgroundProcessor.claimNextPhoto.
*
* Mocks the db so we don't need a live postgres/sqlite — focuses on
* the claim contract: returns null when no rows, returns row + flips
* status to 'processing' when one is available, returns null when a
* race loses the UPDATE-with-guard.
*/
jest.mock('../../src/services/photoProcessor', () => ({
processPhoto: jest.fn(),
processUploadedPhotos: jest.fn(),
queueFilesForProcessing: jest.fn(),
}));
// Build a fake knex instance whose .transaction() takes a callback we can
// drive from the test, and whose query-builder records calls.
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
const queries = [];
const builder = () => {
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
queries.push(recorded);
const chain = {
where: jest.fn(function (...args) {
recorded.wheres.push(args);
return chain;
}),
orderBy: jest.fn(function () {
recorded.ordered = true;
return chain;
}),
forUpdate: jest.fn(function () {
recorded.locked = true;
return chain;
}),
skipLocked: jest.fn(function () {
recorded.skipped = true;
return chain;
}),
first: jest.fn(async function () {
// Only the SELECT chain returns the pending row; the UPDATE chain
// never calls .first().
return pendingRow ? { ...pendingRow } : null;
}),
update: jest.fn(async function (data) {
recorded.updates = data;
return updateResult;
}),
};
return chain;
};
const trxFn = (table) => builder(table);
trxFn.client = { config: { client: clientName } };
trxFn.transaction = async (cb) => cb(trxFn);
// Top-level db('photos') returns same builder for the janitor test path.
const db = trxFn;
return { db, queries };
}
describe('backgroundProcessor.claimNextPhoto', () => {
function loadProcessor(db) {
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
return require('../../src/services/backgroundProcessor');
}
it('returns null when there are no pending photos (postgres path)', async () => {
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the claimed row and flips status (postgres path)', async () => {
const pendingRow = { id: 42, processing_status: 'pending' };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
expect(queries[0].locked).toBe(true);
expect(queries[0].skipped).toBe(true);
// The second query is the status update.
expect(queries[1].updates.processing_status).toBe('processing');
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
});
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
const pendingRow = { id: 7 };
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
const pendingRow = { id: 7 };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// SQLite path: no FOR UPDATE / SKIP LOCKED.
expect(queries[0].locked).toBe(false);
expect(queries[0].skipped).toBe(false);
});
});
@@ -0,0 +1,75 @@
/**
* Unit tests for emailProcessor.htmlToText.
*
* Regression: when a template ships without a body_text, sendTemplateEmail
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
* That regex strips angle-bracket tags but leaves the *contents* of <style>
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
* embeds a 100+ line <style> block) produced a "plain-text" email starting
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { htmlToText } = require('../../src/services/emailProcessor');
describe('htmlToText', () => {
it('returns empty string for empty input', () => {
expect(htmlToText('')).toBe('');
expect(htmlToText(null)).toBe('');
expect(htmlToText(undefined)).toBe('');
});
it('strips <style> blocks and their contents', () => {
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
const out = htmlToText(html);
expect(out).toBe('Hello');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/color/);
});
it('strips <script> blocks and their contents', () => {
const html = '<body><script>alert("x")</script>Hi</body>';
expect(htmlToText(html)).toBe('Hi');
});
it('converts <br> tags to newlines', () => {
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
});
it('keeps a paragraph break between adjacent <p> tags', () => {
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
});
it('decodes the common HTML entities', () => {
expect(htmlToText('Tom &amp; Jerry &lt;3 &quot;hi&quot;'))
.toBe('Tom & Jerry <3 "hi"');
});
it('handles a fully-wrapped email body without leaking CSS rules', () => {
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
// CSS rules followed by the actual content.
const wrapped = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
.email-container { max-width: 600px; }
.button { background-color: #5C8762; color: white !important; }
</style>
</head>
<body>
<h2>Galerie erfolgreich erstellt</h2>
<p>Liebe(r) Natalie,</p>
</body>
</html>`;
const out = htmlToText(wrapped);
expect(out).toContain('Galerie erfolgreich erstellt');
expect(out).toContain('Liebe(r) Natalie');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/font-family/);
expect(out).not.toMatch(/background-color/);
expect(out).not.toMatch(/\.button/);
});
});
@@ -0,0 +1,138 @@
/**
* Unit tests for emailProcessor.safeTemplateReplace.
*
* Covers the two regressions that hit picpeak.nothaft.cloud on the
* 3.32.x betas:
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
* because the renderer only handled {{var}} substitution and the
* shipped templates use Handlebars-style conditionals.
* - {{var}} substitution inside a kept conditional block.
*
* The publish-from-draft password localisation lives inside the wider
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
* sentinel string '(set at creation)' is asserted only at the i18n-map
* level by integration in adminEvents.js.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
describe('safeTemplateReplace', () => {
describe('flat variable substitution', () => {
it('replaces {{var}} with the variable value', () => {
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
.toBe('Hello Paul!');
});
it('leaves unknown variables untouched', () => {
expect(safeTemplateReplace('Hello {{name}}!', {}))
.toBe('Hello {{name}}!');
});
it('coerces non-string values to string', () => {
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
.toBe('Count: 42');
});
it('handles empty templates and missing variables map', () => {
expect(safeTemplateReplace('', { x: 1 })).toBe('');
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
expect(safeTemplateReplace(null, {})).toBe(null);
});
});
describe('{{#if VAR}}…{{/if}} blocks', () => {
it('strips the block when the variable is missing', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, {})).toBe('before after');
});
it('strips the block when the variable is an empty string', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
});
it('strips the block when the variable is null', () => {
const tpl = '{{#if x}}kept{{/if}}';
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
});
it('keeps the block and substitutes inside it when truthy', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
.toBe('before HELLO world after');
});
it('handles multi-line conditional blocks', () => {
const tpl = [
'Liebe(r) {{host_name}},',
'',
'{{#if welcome_message}}',
'Persönliche Nachricht:',
'{{welcome_message}}',
'{{/if}}',
'Galerie-Details:',
].join('\n');
const withMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: 'Schön, dass ihr da seid!',
});
expect(withMsg).toContain('Persönliche Nachricht:');
expect(withMsg).toContain('Schön, dass ihr da seid!');
expect(withMsg).not.toContain('{{#if');
expect(withMsg).not.toContain('{{/if');
const withoutMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: '',
});
expect(withoutMsg).not.toContain('Persönliche Nachricht');
expect(withoutMsg).not.toContain('{{#if');
expect(withoutMsg).not.toContain('{{/if');
expect(withoutMsg).toContain('Liebe(r) Natalie,');
expect(withoutMsg).toContain('Galerie-Details:');
});
it('handles multiple sibling conditionals independently', () => {
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
});
it('treats numeric 0 as falsy', () => {
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
});
});
describe('HTML escaping (escapeHtml: true)', () => {
it('does not escape by default', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
.toBe('Welcome to Test <script>');
});
it('escapes admin-supplied values when opted in', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
.toBe('Welcome to Test &lt;script&gt;alert(1)&lt;/script&gt;');
});
it('escapes both the < > and & characters and quotes', () => {
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
.toBe('&lt;a href=&quot;evil&quot;&gt;A &amp; B&#39;s&lt;/a&gt;');
});
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
const tpl = '<p>{{welcome_message}}</p>';
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
.toBe('<p>Hi<br />there</p>');
});
it('passes server-generated URLs through unescaped', () => {
const tpl = '<a href="{{gallery_link}}">link</a>';
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
});
});
});
@@ -0,0 +1,216 @@
/**
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
* point that runs after a row has been claimed by the background
* processor. Mocks every external dependency and validates the
* happy-path DB updates and side-effect ordering.
*
* jest.mock factories are evaluated before any local variables exist,
* so collaborators are kept inside the mock factories themselves and
* the test reaches into them via require() once they're set up.
*/
const path = require('path');
jest.mock('../../src/database/db', () => {
const recorded = { whereCalls: [], updateCalls: [] };
let pendingWhere = null;
const photosState = { row: null };
const eventsState = { row: null };
function makePhotoQuery() {
return {
where(args) {
pendingWhere = args;
recorded.whereCalls.push(args);
return this;
},
async first() {
return photosState.row;
},
async update(data) {
recorded.updateCalls.push({ where: pendingWhere, data });
return 1;
},
};
}
function makeEventsQuery() {
return {
where() {
return this;
},
async first() {
return eventsState.row;
},
};
}
function dbFn(table) {
if (table === 'photos') return makePhotoQuery();
if (table === 'events') return makeEventsQuery();
throw new Error(`Unexpected table: ${table}`);
}
dbFn.client = { config: { client: 'pg' } };
return {
db: dbFn,
__setPhoto: (row) => { photosState.row = row; },
__setEvent: (row) => { eventsState.row = row; },
__reset: () => {
recorded.whereCalls = [];
recorded.updateCalls = [];
photosState.row = null;
eventsState.row = null;
},
__recorded: () => recorded,
};
});
jest.mock('../../src/services/imageProcessor', () => {
const mockGenerateThumbnail = jest.fn();
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
jest.mock('../../src/services/photoResolver', () => ({
resolvePhotoStorageKey: jest.fn(
(event, photo) => `events/active/${event.slug}/${photo.filename}`
),
}));
jest.mock('../../src/utils/filenameSanitizer', () => ({
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
}));
jest.mock('../../src/services/watermarkGeneratorService', () => ({
generateForPhoto: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/services/webhookService', () => ({
fire: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
// Stub sharp so we don't actually read any image off disk.
jest.mock('sharp', () => {
const mock = jest.fn(() => ({
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
}));
return mock;
});
const dbModule = require('../../src/database/db');
const imageProcessor = require('../../src/services/imageProcessor');
const videoProcessor = require('../../src/services/videoProcessor');
const watermarkService = require('../../src/services/watermarkGeneratorService');
const webhookService = require('../../src/services/webhookService');
beforeEach(() => {
dbModule.__reset();
jest.clearAllMocks();
});
describe('photoProcessor.processPhoto', () => {
it('marks an image complete with thumbnail and dimensions', async () => {
dbModule.__setPhoto({
id: 101,
event_id: 5,
filename: 'wedding-001.jpg',
original_filename: 'IMG_0001.jpg',
mime_type: 'image/jpeg',
media_type: 'image',
size_bytes: 12345,
captured_at: null,
processing_status: 'processing',
});
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(101);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.processing_error).toBeNull();
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
expect(finalUpdate.data.width).toBe(1920);
expect(finalUpdate.data.height).toBe(1080);
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
expect(webhookService.fire).toHaveBeenCalledWith(
'photo.uploaded',
expect.objectContaining({
event: expect.objectContaining({ slug: 'wedding' }),
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
})
);
});
it('handles videos with ffmpeg metadata path', async () => {
dbModule.__setPhoto({
id: 202,
event_id: 9,
filename: 'wedding-video-001.mp4',
original_filename: 'movie.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 99999,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
metadata: {
duration: 12.5,
videoCodec: 'h264',
audioCodec: 'aac',
width: 1280,
height: 720,
},
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(202);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.duration).toBe(12.5);
expect(finalUpdate.data.video_codec).toBe('h264');
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
// Watermark queue is image-only.
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
const { processPhoto } = require('../../src/services/photoProcessor');
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
});
});
void path; // referenced indirectly via mocks
@@ -0,0 +1,66 @@
/**
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
* so admin-supplied welcome messages can't inject markup into customer mail.
*/
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
describe('escapeHtml', () => {
it('escapes the five HTML metacharacters', () => {
expect(escapeHtml('& < > " \'')).toBe('&amp; &lt; &gt; &quot; &#39;');
});
it('returns empty string for null/undefined', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('coerces non-string values', () => {
expect(escapeHtml(42)).toBe('42');
});
it('escapes & before introducing new entities', () => {
expect(escapeHtml('<&>')).toBe('&lt;&amp;&gt;');
});
});
describe('nl2br', () => {
it('joins non-empty lines with <br />', () => {
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
});
it('normalises CRLF and CR', () => {
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
});
it('drops empty lines', () => {
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
});
it('returns empty for empty input', () => {
expect(nl2br('')).toBe('');
expect(nl2br(null)).toBe('');
});
});
describe('formatWelcomeMessage', () => {
it('returns empty string for empty input', () => {
expect(formatWelcomeMessage('')).toBe('');
expect(formatWelcomeMessage(' ')).toBe('');
});
it('escapes HTML metacharacters before nl2br', () => {
expect(formatWelcomeMessage('Hello <b>world</b>'))
.toBe('Hello &lt;b&gt;world&lt;/b&gt;');
});
it('renders newlines as <br /> while keeping content escaped', () => {
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
.toBe('line 1<br />&lt;script&gt;x&lt;/script&gt;<br />line 3');
});
it('escapes ampersands and quotes that would otherwise break HTML', () => {
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
.toBe('Tom &amp; Jerry&#39;s &quot;show&quot;');
});
});
Binary file not shown.
+4 -4
View File
@@ -1831,8 +1831,8 @@
}
},
"nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"overridden": false
},
"nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1"
},
"tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"overridden": false
},
"tunnel-agent": {
+2 -1
View File
@@ -1,6 +1,6 @@
# Database Migrations
This directory contains database migrations for the Wedding Photo Sharing platform.
This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
+2 -2
View File
@@ -14,8 +14,8 @@ exports.up = async function(knex) {
// Create default admin user if none exists
const adminExists = await knex('admin_users').first();
if (!adminExists) {
// Generate a secure random password
const generatedPassword = generateReadablePassword();
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
// Get admin credentials from environment or use defaults
@@ -0,0 +1,48 @@
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
exports.up = async function up(knex) {
const settingKey = 'general_max_files_per_upload';
const existing = await knex('app_settings')
.where({ setting_key: settingKey })
.first();
if (existing) {
// Normalize existing value into allowed bounds
let parsedValue;
try {
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
} catch {
parsedValue = existing.setting_value;
}
const numeric = Number(parsedValue);
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
if (Number.isFinite(numeric) && numeric >= 1) {
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
}
if (normalized !== numeric) {
await knex('app_settings')
.where({ setting_key: settingKey })
.update({
setting_value: JSON.stringify(normalized),
updated_at: new Date()
});
}
return;
}
await knex('app_settings').insert({
setting_key: settingKey,
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
setting_type: 'general',
updated_at: new Date()
});
};
exports.down = async function down(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.del();
};
@@ -0,0 +1,44 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
table.string('customer_name');
});
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
table.string('customer_email');
});
// Backfill new columns from legacy host_* fields
const client = knex?.client?.config?.client;
if (client === 'pg') {
await knex.raw(`
UPDATE events
SET customer_name = COALESCE(customer_name, host_name),
customer_email = COALESCE(customer_email, host_email)
`);
} else {
// SQLite fallback
await knex('events').update({
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
customer_email: knex.raw('COALESCE(customer_email, host_email)')
});
}
};
exports.down = async function down(knex) {
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
if (hasCustomerName) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_name');
});
}
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
if (hasCustomerEmail) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_email');
});
}
};
@@ -0,0 +1,18 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
// Add tls_reject_unauthorized column to email_configs table
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
table.boolean('tls_reject_unauthorized').defaultTo(true);
});
};
exports.down = async function down(knex) {
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
if (hasColumn) {
await knex.schema.alterTable('email_configs', (table) => {
table.dropColumn('tls_reject_unauthorized');
});
}
};
@@ -0,0 +1,109 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* Migration: Add video support to photos table
* - Adds columns for video metadata (media_type, duration, codecs, dimensions)
* - Updates existing photos to have media_type 'image'
*/
exports.up = async function(knex) {
console.log('Running migration: 042_add_video_support');
// Add media_type column (image or video)
await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => {
table.string('media_type').defaultTo('image');
});
// Add mime_type column if not exists
await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => {
table.string('mime_type');
});
// Add duration column (for videos, in seconds)
await addColumnIfNotExists(knex, 'photos', 'duration', (table) => {
table.integer('duration');
});
// Add video codec information
await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => {
table.string('video_codec');
});
// Add audio codec information
await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => {
table.string('audio_codec');
});
// Add width dimension
await addColumnIfNotExists(knex, 'photos', 'width', (table) => {
table.integer('width');
});
// Add height dimension
await addColumnIfNotExists(knex, 'photos', 'height', (table) => {
table.integer('height');
});
// Update existing photos to have media_type 'image' if not set
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex('photos')
.whereNull('media_type')
.orWhere('media_type', '')
.update({ media_type: 'image' });
console.log('Updated existing photos to have media_type "image"');
}
console.log('Migration 042_add_video_support completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 042_add_video_support');
// Remove video support columns
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('media_type');
});
}
const hasDuration = await knex.schema.hasColumn('photos', 'duration');
if (hasDuration) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('duration');
});
}
const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec');
if (hasVideoCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('video_codec');
});
}
const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec');
if (hasAudioCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('audio_codec');
});
}
const hasWidth = await knex.schema.hasColumn('photos', 'width');
if (hasWidth) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('width');
});
}
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (hasHeight) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('height');
});
}
// Note: We don't drop mime_type as it may be used by images as well
console.log('Rollback of 042_add_video_support completed');
};
@@ -0,0 +1,21 @@
/**
* Migration: Add slug_redirects table for event rename feature
* This table stores old slugs that should redirect to new slugs
*/
exports.up = function(knex) {
return knex.schema.createTable('slug_redirects', (table) => {
table.increments('id').primary();
table.string('old_slug', 255).notNullable().unique();
table.string('new_slug', 255).notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Index for fast lookup
table.index('old_slug');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('slug_redirects');
};
@@ -0,0 +1,33 @@
/**
* Migration: Add optional event fields settings
* These settings control whether customer name, customer email, and admin email
* are required when creating new events.
*/
exports.up = async function(knex) {
const settings = [
{ setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = function(knex) {
return knex('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.del();
};
@@ -0,0 +1,100 @@
/**
* Migration: Add indexes for photo filtering performance
* These indexes optimize queries that filter by feedback metrics
*/
exports.up = async function(knex) {
// Add comment_count column if it doesn't exist
const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count');
if (!hasCommentCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('comment_count').defaultTo(0);
});
}
// Add indexes for common filter queries
// Note: PostgreSQL supports partial indexes, SQLite does not
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// Partial indexes for PostgreSQL
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
WHERE average_rating > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
WHERE like_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
WHERE favorite_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
WHERE comment_count > 0
`);
} else {
// Regular indexes for SQLite
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
`);
}
// Create export_jobs table for tracking large exports
const hasExportJobs = await knex.schema.hasTable('export_jobs');
if (!hasExportJobs) {
await knex.schema.createTable('export_jobs', (table) => {
table.increments('id').primary();
table.string('job_id', 50).unique().notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
table.string('format', 20).notNullable();
table.string('status', 20).defaultTo('pending');
table.integer('progress').defaultTo(0);
table.integer('total_photos');
table.json('options');
table.string('file_path', 500);
table.bigInteger('file_size');
table.text('error_message');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('completed_at');
});
}
};
exports.down = async function(knex) {
// Drop indexes
await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter');
// Drop export_jobs table
await knex.schema.dropTableIfExists('export_jobs');
// Note: We don't remove comment_count column as it might have data
};
@@ -0,0 +1,192 @@
/**
* Migration: Add CSS Templates feature
* Creates css_templates table and adds css_template_id to events table
*/
// Default CSS template content
const DEFAULT_CSS_TEMPLATE = `/*
* PicPeak Custom CSS Template: Elegant Dark
*
* Available CSS Custom Properties:
* --gallery-bg: Background color
* --gallery-text: Primary text color
* --gallery-accent: Accent/highlight color
* --gallery-border: Border color
* --gallery-shadow: Box shadow value
* --gallery-radius: Border radius value
* --gallery-spacing: Base spacing unit
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--gallery-bg: #1a1a2e;
--gallery-bg-secondary: #16213e;
--gallery-text: #eaeaea;
--gallery-text-muted: #8b8b9a;
--gallery-accent: #e94560;
--gallery-accent-hover: #ff6b6b;
--gallery-border: #2d2d44;
--gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--gallery-radius: 12px;
--gallery-spacing: 16px;
}
/* ===== Page Background ===== */
.gallery-page {
background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%);
min-height: 100vh;
color: var(--gallery-text);
}
/* ===== Gallery Header ===== */
.gallery-header {
background: rgba(22, 33, 62, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--gallery-border);
padding: calc(var(--gallery-spacing) * 2);
}
.gallery-title {
color: var(--gallery-text);
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
}
/* ===== Photo Cards ===== */
.photo-card {
background: var(--gallery-bg-secondary);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid var(--gallery-border);
}
.photo-card:hover {
transform: translateY(-4px);
box-shadow: var(--gallery-shadow);
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
.photo-card:hover img {
transform: scale(1.05);
}
/* ===== Buttons ===== */
.gallery-btn {
background: var(--gallery-accent);
color: white;
border: none;
border-radius: calc(var(--gallery-radius) / 2);
padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing);
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.gallery-btn:hover {
background: var(--gallery-accent-hover);
transform: translateY(-2px);
}
/* ===== Lightbox ===== */
.lightbox-overlay {
background: rgba(10, 10, 20, 0.95);
backdrop-filter: blur(20px);
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-spacing: 12px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.gallery-title {
font-size: 1.5rem;
}
}`;
exports.up = async function(knex) {
// Create css_templates table
const hasTable = await knex.schema.hasTable('css_templates');
if (!hasTable) {
await knex.schema.createTable('css_templates', (table) => {
table.increments('id').primary();
table.integer('slot_number').notNullable();
table.string('name', 50).notNullable().defaultTo('Untitled');
table.text('css_content').notNullable().defaultTo('');
table.boolean('is_enabled').notNullable().defaultTo(false);
table.boolean('is_default').notNullable().defaultTo(false);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique('slot_number');
});
// Insert default templates
await knex('css_templates').insert([
{
slot_number: 1,
name: 'Elegant Dark',
css_content: DEFAULT_CSS_TEMPLATE,
is_enabled: true,
is_default: true
},
{
slot_number: 2,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
},
{
slot_number: 3,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
}
]);
}
// Add css_template_id to events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
});
}
};
exports.down = async function(knex) {
// Remove css_template_id from events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('css_template_id');
});
}
// Drop css_templates table
await knex.schema.dropTableIfExists('css_templates');
};
// Export default template for use in reset functionality
module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE;
@@ -0,0 +1,731 @@
/**
* Migration: Add Liquid Glass CSS Templates
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
*
* These are starter example templates for new installations.
* Users can edit or replace them as needed.
*/
const APPLE_LIQUID_GLASS = `/*
* PicPeak Custom CSS Template: Apple Liquid Glass
* Authentic iOS 26 / macOS Tahoe Liquid Glass Design
*/
/* ===== Apple System Fonts ===== */
.gallery-page,
.gallery-page *,
.gallery-sidebar,
.gallery-sidebar * {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===== CSS Variables ===== */
:root {
--glass-blur: 20px;
--glass-blur-heavy: 40px;
--glass-saturation: 180%;
--glass-bg: rgba(255, 255, 255, 0.08);
--glass-bg-medium: rgba(255, 255, 255, 0.18);
--glass-bg-solid: rgba(255, 255, 255, 0.25);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-inset: inset 0 1px 1px rgba(255, 255, 255, 0.4),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
--gallery-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
--gallery-text: #1a1a2e;
--gallery-text-light: #ffffff;
--gallery-accent: #667eea;
--gallery-radius: 20px;
--gallery-radius-sm: 12px;
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-gradient) !important;
background-attachment: fixed !important;
min-height: 100vh;
}
.gallery-page::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 600px 400px at 15% 85%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
radial-gradient(ellipse 500px 350px at 85% 15%, rgba(255, 255, 255, 0.15) 0%, transparent 45%);
pointer-events: none;
z-index: 0;
}
/* ===== TOP BAR / HEADER - Liquid Glass ===== */
.gallery-page .gallery-header,
.gallery-page header {
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-bottom: 1px solid var(--glass-border) !important;
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
}
.gallery-page .gallery-header > div {
background: transparent !important;
border: none !important;
}
/* ===== SIDEBAR - Liquid Glass ===== */
.gallery-sidebar {
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-right: 1px solid var(--glass-border) !important;
box-shadow: 4px 0 32px rgba(31, 38, 135, 0.1), var(--glass-inset) !important;
}
.gallery-sidebar h2,
.gallery-sidebar h3 {
color: var(--gallery-text) !important;
font-weight: 600 !important;
}
/* ===== HERO LAYOUT - Transform to Glass Title Box ===== */
/* Target the hero wrapper */
.gallery-page .relative.-mt-6 {
margin-top: 0 !important;
}
/* Target the hero section (first child with h-[60vh]) */
.gallery-page .relative.-mt-6 > .relative:first-child {
height: auto !important;
min-height: auto !important;
margin: 0 !important;
padding: 2rem !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
background: transparent !important;
}
/* Hide the hero background image */
.gallery-page .relative.-mt-6 > .relative:first-child > img,
.gallery-page .relative.-mt-6 > .relative:first-child > canvas {
display: none !important;
}
/* Hide the dark overlay */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.bg-black {
display: none !important;
}
/* Style the content area as glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
position: relative !important;
inset: auto !important;
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border-light) !important;
border-radius: var(--gallery-radius) !important;
padding: 2rem 3rem !important;
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
max-width: 600px !important;
width: auto !important;
}
/* Hide logo in glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child .mb-6 {
display: none !important;
}
/* Style title text */
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
color: var(--gallery-text) !important;
text-shadow: none !important;
font-weight: 700 !important;
font-size: 2.25rem !important;
margin-bottom: 0.75rem !important;
}
/* Style date text */
.gallery-page .relative.-mt-6 > .relative:first-child .text-white\\/90 {
color: var(--gallery-text) !important;
opacity: 0.8;
}
/* Hide scroll down button */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.bottom-8,
.gallery-page .relative.-mt-6 > .relative:first-child > button.absolute {
display: none !important;
}
/* ===== PHOTO GRID ===== */
.gallery-page .photo-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
gap: 1.25rem !important;
padding: 1rem !important;
}
/* ===== PHOTO CARDS - Liquid Glass ===== */
.gallery-page .photo-card {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius) !important;
overflow: hidden !important;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), var(--glass-inset) !important;
}
.gallery-page .photo-card:hover {
transform: translateY(-6px) scale(1.02) !important;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.25),
0 8px 16px rgba(0, 0, 0, 0.1),
var(--glass-inset) !important;
border-color: var(--glass-border-light) !important;
}
.gallery-page .photo-card img {
transition: transform 0.4s ease !important;
}
.gallery-page .photo-card:hover img {
transform: scale(1.05) !important;
}
/* ===== BUTTONS - Glass Pill Style ===== */
.gallery-page button,
.gallery-page [role="button"],
.gallery-sidebar button {
background: var(--glass-bg) !important;
backdrop-filter: blur(12px) saturate(150%) !important;
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
border: 1px solid var(--glass-border) !important;
border-radius: 9999px !important;
color: var(--gallery-text) !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
.gallery-page button:hover,
.gallery-page [role="button"]:hover,
.gallery-sidebar button:hover {
background: var(--glass-bg-medium) !important;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
.gallery-page button[class*="bg-primary"],
.gallery-page .gallery-btn-download {
background: linear-gradient(135deg, var(--gallery-accent) 0%, #764ba2 100%) !important;
color: white !important;
border: none !important;
}
/* ===== INPUT FIELDS ===== */
.gallery-page input,
.gallery-page select,
.gallery-sidebar input,
.gallery-sidebar select {
background: rgba(255, 255, 255, 0.25) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius-sm) !important;
color: var(--gallery-text) !important;
}
/* Input placeholder text - make it visible */
.gallery-page input::placeholder,
.gallery-sidebar input::placeholder {
color: rgba(26, 26, 46, 0.6) !important;
opacity: 1 !important;
}
/* Input focus state */
.gallery-page input:focus,
.gallery-sidebar input:focus {
background: rgba(255, 255, 255, 0.35) !important;
border-color: var(--glass-border-light) !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
}
/* ===== FOOTER ===== */
.gallery-page .gallery-footer,
.gallery-page footer {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border-top: 1px solid var(--glass-border) !important;
}
/* ===== LIGHTBOX ===== */
.gallery-page [class*="fixed"][class*="inset-0"][class*="z-50"] {
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(30px) !important;
-webkit-backdrop-filter: blur(30px) !important;
}
/* ===== SCROLLBAR ===== */
.gallery-page ::-webkit-scrollbar,
.gallery-sidebar ::-webkit-scrollbar {
width: 8px;
}
.gallery-page ::-webkit-scrollbar-track,
.gallery-sidebar ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.gallery-page ::-webkit-scrollbar-thumb,
.gallery-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
:root {
--gallery-radius: 16px;
--glass-blur: 16px;
}
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
padding: 1.5rem 2rem !important;
max-width: 90% !important;
}
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
font-size: 1.5rem !important;
}
.gallery-page .photo-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.75rem !important;
}
}
/* ===== ACCESSIBILITY ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page .photo-card,
.gallery-page button {
transition: none !important;
}
.gallery-page .photo-card:hover {
transform: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.gallery-page .photo-card,
.gallery-page button,
.gallery-sidebar {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.95) !important;
}
}`;
const LIQUID_GLASS_DARK = `/*
* PicPeak Custom CSS Template: Liquid Glass Dark
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Deep translucent dark surfaces
* - Neon accent highlights
* - Dramatic glass reflections
* - Subtle animated gradients
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(15, 15, 35, 0.7);
--glass-bg-elevated: rgba(25, 25, 55, 0.85);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-highlight: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-blur: 24px;
--glass-saturation: 150%;
--gallery-bg: #0a0a1a;
--gallery-text: #f0f0f5;
--gallery-text-muted: rgba(240, 240, 245, 0.6);
--gallery-accent: #00d4ff;
--gallery-accent-secondary: #ff00e5;
--gallery-accent-hover: #00ffea;
--gallery-radius: 20px;
--gallery-spacing: 20px;
/* Neon glow variables */
--neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2);
--neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2);
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
/* Animated mesh gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background:
radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%),
radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%),
radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%);
animation: gradientShift 20s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gradientShift {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(2%, 2%) rotate(1deg); }
50% { transform: translate(-1%, 3%) rotate(-1deg); }
75% { transform: translate(3%, -2%) rotate(2deg); }
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation));
border-bottom: 1px solid var(--glass-border-highlight);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Dark Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Top highlight reflection */
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
z-index: 2;
}
/* Inner glow effect */
.photo-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--gallery-radius);
padding: 1px;
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0) 0%,
rgba(0, 212, 255, 0) 40%,
rgba(0, 212, 255, 0.1) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: var(--glass-border-highlight);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(0, 212, 255, 0.2),
var(--neon-glow);
}
.photo-card:hover::after {
opacity: 1;
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
}
.photo-card:hover img {
transform: scale(1.05);
filter: brightness(1);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.4) 100%
);
color: var(--gallery-text);
}
.photo-card-info p {
color: var(--gallery-text-muted);
font-size: 0.875rem;
}
/* ===== Buttons - Neon Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.gallery-btn:hover {
border-color: var(--gallery-accent);
box-shadow: var(--neon-glow);
color: var(--gallery-accent);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border: none;
box-shadow: var(--neon-glow);
}
.gallery-btn-primary:hover {
box-shadow:
0 0 30px rgba(0, 212, 255, 0.6),
0 0 60px rgba(0, 212, 255, 0.3),
0 0 90px rgba(255, 0, 229, 0.2);
transform: translateY(-2px);
}
/* ===== Lightbox - Dark Glass ===== */
.lightbox-overlay {
background: rgba(5, 5, 15, 0.9);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg-elevated);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border-highlight);
border-radius: var(--gallery-radius);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.5),
var(--neon-glow);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text-muted);
transition: all 0.3s ease;
}
.category-pill:hover {
border-color: var(--gallery-accent);
color: var(--gallery-accent);
box-shadow: var(--neon-glow);
}
.category-pill.active {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border-color: transparent;
box-shadow: var(--neon-glow);
}
/* ===== Scrollbar Styling ===== */
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gallery-page ::-webkit-scrollbar-track {
background: var(--glass-bg);
border-radius: 4px;
}
.gallery-page ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
border-radius: 4px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
/* Reduce animation complexity on mobile */
.gallery-page::before {
animation: none;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page::before {
animation: none;
}
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.photo-card,
.gallery-btn,
.gallery-header {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.gallery-page {
--glass-bg: rgba(20, 20, 40, 0.98);
--glass-bg-elevated: rgba(30, 30, 60, 0.98);
}
}`;
exports.up = async function(knex) {
// Update template slot 2 with Apple Liquid Glass (Light)
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Apple Liquid Glass',
css_content: APPLE_LIQUID_GLASS,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
// Update template slot 3 with Liquid Glass Dark
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Liquid Glass Dark',
css_content: LIQUID_GLASS_DARK,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
};
exports.down = async function(knex) {
// Revert to empty templates
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
};
// Export templates for use elsewhere
module.exports.APPLE_LIQUID_GLASS = APPLE_LIQUID_GLASS;
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
@@ -0,0 +1,91 @@
/**
* Migration: Add Roles Table
* Creates the roles table for RBAC multi-administrator support.
*
* Default roles:
* - super_admin (priority 100): Full system access including user management
* - admin (priority 80): Full event and photo management
* - editor (priority 50): Can edit events and photos but not create or delete
* - viewer (priority 20): Read-only access to dashboard and events
*/
exports.up = async function(knex) {
console.log('Creating roles table...');
// Check if table already exists
const hasRolesTable = await knex.schema.hasTable('roles');
if (!hasRolesTable) {
await knex.schema.createTable('roles', (table) => {
table.increments('id').primary();
table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
table.text('description');
table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Index for name lookups
table.index(['name']);
// Index for priority-based ordering
table.index(['priority']);
});
console.log('Roles table created');
}
// Insert default system roles
const existingRoles = await knex('roles').select('name');
const existingRoleNames = existingRoles.map(r => r.name);
const defaultRoles = [
{
name: 'super_admin',
display_name: 'Super Admin',
description: 'Full system access including user management',
is_system: true,
priority: 100
},
{
name: 'admin',
display_name: 'Admin',
description: 'Full event and photo management',
is_system: true,
priority: 80
},
{
name: 'editor',
display_name: 'Editor',
description: 'Can edit events and photos but not create or delete',
is_system: true,
priority: 50
},
{
name: 'viewer',
display_name: 'Viewer',
description: 'Read-only access to dashboard and events',
is_system: true,
priority: 20
}
];
const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
if (rolesToInsert.length > 0) {
await knex('roles').insert(rolesToInsert);
console.log(`Inserted ${rolesToInsert.length} default roles`);
}
console.log('Roles table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing roles table...');
// Note: This will fail if there are foreign key references
// The role_permissions and admin_users tables must be rolled back first
await knex.schema.dropTableIfExists('roles');
console.log('Roles table removed');
};
@@ -0,0 +1,122 @@
/**
* Migration: Add Permissions Table
* Creates the permissions table for granular access control.
*
* Permission categories:
* - events: View, create, edit, delete, archive events
* - photos: View, upload, edit, delete, download photos
* - archives: View, restore, download, delete archives
* - analytics: View analytics and statistics
* - email: View, edit, send emails
* - branding: View and edit branding settings
* - cms: View and edit CMS pages
* - settings: View and edit application settings
* - backup: View, create, restore, delete backups
* - users: View, create, edit, delete admin users (Super Admin only)
* - activity: View and export activity logs
*/
exports.up = async function(knex) {
console.log('Creating permissions table...');
// Check if table already exists
const hasPermissionsTable = await knex.schema.hasTable('permissions');
if (!hasPermissionsTable) {
await knex.schema.createTable('permissions', (table) => {
table.increments('id').primary();
table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
table.string('display_name', 150).notNullable();
table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
table.text('description');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['name']);
table.index(['category']);
});
console.log('Permissions table created');
}
// Check for existing permissions
const existingPermissions = await knex('permissions').select('name');
const existingPermissionNames = existingPermissions.map(p => p.name);
// Define all permissions
const permissions = [
// Events
{ name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
{ name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
{ name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
{ name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
{ name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
// Photos
{ name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
{ name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
{ name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
{ name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
{ name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
// Archives
{ name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
{ name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
{ name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
{ name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
// Analytics
{ name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
// Email
{ name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
{ name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
{ name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
// Branding & CMS
{ name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
{ name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
{ name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
{ name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
// Settings
{ name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
{ name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
// Backup
{ name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
{ name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
{ name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
{ name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
// User Management (Super Admin only)
{ name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
{ name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
{ name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
{ name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
// Activity Logs
{ name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
{ name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
];
// Filter out already existing permissions
const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
if (permissionsToInsert.length > 0) {
await knex('permissions').insert(permissionsToInsert);
console.log(`Inserted ${permissionsToInsert.length} permissions`);
}
console.log('Permissions table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing permissions table...');
// Note: This will fail if there are foreign key references
// The role_permissions table must be rolled back first
await knex.schema.dropTableIfExists('permissions');
console.log('Permissions table removed');
};
@@ -0,0 +1,134 @@
/**
* Migration: Add Role Permissions Junction Table
* Creates the junction table mapping permissions to roles.
*
* Role permission mappings:
* - super_admin: All permissions
* - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
* - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
* - viewer: View-only access to events, photos, archives, analytics, branding, cms
*/
exports.up = async function(knex) {
console.log('Creating role_permissions junction table...');
// Check if table already exists
const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
if (!hasRolePermissionsTable) {
await knex.schema.createTable('role_permissions', (table) => {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
// Indexes for efficient lookups
table.index(['role_id']);
table.index(['permission_id']);
});
console.log('Role permissions junction table created');
}
// Get role and permission IDs
const roles = await knex('roles').select('id', 'name');
const permissions = await knex('permissions').select('id', 'name');
if (roles.length === 0 || permissions.length === 0) {
console.log('No roles or permissions found, skipping permission mappings');
return;
}
const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
// Define role-permission mappings
const rolePermissions = {
super_admin: permissions.map(p => p.name), // All permissions
admin: [
// Events - full access
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
// Photos - full access
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
// Archives - full access
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
// Analytics - view only
'analytics.view',
// Email - full access
'email.view', 'email.edit', 'email.send',
// Branding - full access
'branding.view', 'branding.edit',
// CMS - full access
'cms.view', 'cms.edit',
// Settings - view only
'settings.view',
// Backup - view and create only
'backup.view', 'backup.create',
// Activity - view only
'activity.view'
],
editor: [
// Events - view, create, and edit (can only see their own events)
'events.view', 'events.create', 'events.edit',
// Photos - view, upload, edit (no delete)
'photos.view', 'photos.upload', 'photos.edit',
// Analytics - view only
'analytics.view',
// Activity - view only
'activity.view'
],
viewer: [
// Events - view only
'events.view',
// Photos - view only
'photos.view',
// Archives - view only
'archives.view',
// Analytics - view only
'analytics.view',
// Branding - view only
'branding.view',
// CMS - view only
'cms.view'
]
};
// Check for existing mappings to avoid duplicates
const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
// Build insert list
const inserts = [];
for (const [roleName, perms] of Object.entries(rolePermissions)) {
for (const permName of perms) {
if (roleMap[roleName] && permMap[permName]) {
const key = `${roleMap[roleName]}-${permMap[permName]}`;
if (!existingSet.has(key)) {
inserts.push({
role_id: roleMap[roleName],
permission_id: permMap[permName]
});
}
}
}
}
if (inserts.length > 0) {
// Insert in batches to avoid hitting database limits
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
const batch = inserts.slice(i, i + batchSize);
await knex('role_permissions').insert(batch);
}
console.log(`Inserted ${inserts.length} role-permission mappings`);
}
console.log('Role permissions junction table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role_permissions junction table...');
await knex.schema.dropTableIfExists('role_permissions');
console.log('Role permissions junction table removed');
};
@@ -0,0 +1,115 @@
/**
* Migration: Add Role to Admin Users
* Adds RBAC-related columns to the admin_users table:
* - role_id: Foreign key to roles table
* - created_by: Foreign key to admin_users (who invited this user)
* - invite_token: Token for invitation acceptance (64 chars = 256 bits)
* - invite_expires_at: When the invitation token expires
* - invite_accepted_at: When the user accepted the invitation
*
* Also migrates existing admin users to super_admin role.
*/
exports.up = async function(knex) {
console.log('Adding role columns to admin_users table...');
// Check if columns already exist
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
// Add new columns if they don't exist
if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
await knex.schema.alterTable('admin_users', (table) => {
if (!hasRoleId) {
// Note: We add as nullable first, then set values, then alter to not null
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
}
if (!hasCreatedBy) {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
}
if (!hasInviteToken) {
// 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
table.string('invite_token', 64);
}
if (!hasInviteExpiresAt) {
table.timestamp('invite_expires_at');
}
if (!hasInviteAcceptedAt) {
table.timestamp('invite_accepted_at');
}
});
console.log('Role columns added to admin_users table');
}
// Add index on invite_token for fast lookup
const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
if (hasInviteTokenIndex) {
// Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
try {
await knex.schema.alterTable('admin_users', (table) => {
table.index(['invite_token']);
});
} catch (e) {
// Index may already exist
if (!e.message.includes('already exists')) {
console.log('Note: invite_token index may already exist');
}
}
}
// Get super_admin role ID
const superAdminRole = await knex('roles').where('name', 'super_admin').first();
if (superAdminRole) {
// Migrate existing admin users without a role to super_admin
const usersWithoutRole = await knex('admin_users')
.whereNull('role_id')
.select('id');
if (usersWithoutRole.length > 0) {
await knex('admin_users')
.whereNull('role_id')
.update({ role_id: superAdminRole.id });
console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
}
} else {
console.log('Warning: super_admin role not found. Run migration 054 first.');
}
console.log('Admin users role migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role columns from admin_users table...');
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
await knex.schema.alterTable('admin_users', (table) => {
if (hasInviteAcceptedAt) {
table.dropColumn('invite_accepted_at');
}
if (hasInviteExpiresAt) {
table.dropColumn('invite_expires_at');
}
if (hasInviteToken) {
table.dropColumn('invite_token');
}
if (hasCreatedBy) {
table.dropColumn('created_by');
}
if (hasRoleId) {
table.dropColumn('role_id');
}
});
console.log('Role columns removed from admin_users table');
};
@@ -0,0 +1,68 @@
/**
* Migration: Add Admin Invitations Table
* Creates the admin_invitations table for managing pending admin user invitations.
*
* Security features:
* - Token is 64 characters (32 bytes hex = 256 bits of entropy)
* - Tokens are unique and indexed for fast lookup
* - Invitations have expiration timestamps
* - Tracks who invited whom and when accepted
* - Foreign key constraints with appropriate CASCADE behavior
*/
exports.up = async function(knex) {
console.log('Creating admin_invitations table...');
// Check if table already exists
const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
if (!hasAdminInvitationsTable) {
await knex.schema.createTable('admin_invitations', (table) => {
table.increments('id').primary();
// Email of the invited user
table.string('email', 255).notNullable();
// Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
// Cryptographically secure for one-time use tokens
table.string('token', 64).unique().notNullable();
// Role to assign when invitation is accepted
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
// Who created this invitation
table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
// When the invitation expires (typically 7 days from creation)
table.timestamp('expires_at').notNullable();
// When the invitation was accepted (null if pending)
table.timestamp('accepted_at');
// The admin_user ID created when invitation was accepted (for audit trail)
table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
// When the invitation was created
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['token']); // Fast token validation
table.index(['email']); // Check for existing invitations by email
table.index(['expires_at']); // Cleanup expired invitations
table.index(['invited_by']); // List invitations by inviter
table.index(['accepted_at']); // Filter pending vs accepted
});
console.log('Admin invitations table created');
}
console.log('Admin invitations table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing admin_invitations table...');
await knex.schema.dropTableIfExists('admin_invitations');
console.log('Admin invitations table removed');
};
@@ -0,0 +1,314 @@
/**
* Migration to add email templates for admin invitation and password reset
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// First, ensure the email_templates table has multilingual columns
// This is needed for fresh installations where legacy migrations don't run
const columnInfo = await knex('email_templates').columnInfo();
if (!columnInfo.subject_en) {
// Need to add multilingual columns
console.log('Adding multilingual columns to email_templates table...');
// Check if we're using SQLite or PostgreSQL
const client = knex.client.config.client;
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
if (isSqlite) {
// SQLite doesn't support column rename directly in all versions
// We need to recreate the table with new structure
// Get existing data
const existingData = await knex('email_templates').select('*');
// Drop the old table
await knex.schema.dropTable('email_templates');
// Create new table with multilingual columns
await knex.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable();
table.string('subject_en');
table.string('subject_de');
table.text('body_html_en');
table.text('body_html_de');
table.text('body_text_en');
table.text('body_text_de');
table.json('variables');
table.datetime('updated_at').defaultTo(knex.fn.now());
});
// Re-insert existing data with column mapping
for (const row of existingData) {
await knex('email_templates').insert({
template_key: row.template_key,
subject_en: row.subject,
subject_de: row.subject, // Copy to German as default
body_html_en: row.body_html,
body_html_de: row.body_html,
body_text_en: row.body_text,
body_text_de: row.body_text,
variables: row.variables,
updated_at: row.updated_at
});
}
console.log('Migrated email_templates table to multilingual structure');
} else {
// PostgreSQL supports ALTER TABLE for column operations
await knex.schema.alterTable('email_templates', (table) => {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
await knex.schema.alterTable('email_templates', (table) => {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy English values to German as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
const existingKeys = existingTemplates.map(t => t.template_key);
// Admin Invitation Email Template
if (!existingKeys.includes('admin_invitation')) {
await knex('email_templates').insert({
template_key: 'admin_invitation',
subject_en: 'You have been invited to join PicPeak as {{role_name}}',
subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
body_html_en: `
<h2>Welcome to PicPeak!</h2>
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
</div>
<p>To accept this invitation and set up your account, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
</div>
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
If the button above does not work, copy and paste this link into your browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Welcome to PicPeak!
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
Your Role: {{role_name}}
This role grants you access to manage and administer the photo sharing platform.
To accept this invitation and set up your account, visit the following link:
{{invite_link}}
IMPORTANT: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Willkommen bei PicPeak!</h2>
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
</div>
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
</div>
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Willkommen bei PicPeak!
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
Ihre Rolle: {{role_name}}
Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
Um diese Einladung anzunehmen und Ihr Konto einzurichten, besuchen Sie den folgenden Link:
{{invite_link}}
WICHTIG: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['invite_link', 'role_name', 'expires_at'])
});
}
// Admin Password Reset Email Template
if (!existingKeys.includes('admin_password_reset')) {
await knex('email_templates').insert({
template_key: 'admin_password_reset',
subject_en: 'Your PicPeak administrator password has been reset',
subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt',
body_html_en: `
<h2>Password Reset Notification</h2>
<p>Hello <strong>{{username}}</strong>,</p>
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>This is a temporary password. Please change it immediately after logging in.</li>
<li>Never share your password with anyone.</li>
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
</ul>
</div>
<p>To log in to the admin panel, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
</div>
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Password Reset Notification
Hello {{username}},
Your administrator password for PicPeak has been reset by a system administrator.
Your New Login Credentials:
- Username: {{username}}
- Temporary Password: {{new_password}}
SECURITY NOTICE:
- This is a temporary password. Please change it immediately after logging in.
- Never share your password with anyone.
- If you did not request this password reset, please contact your system administrator immediately.
To log in to the admin panel, visit: {{admin_login_url}}
After logging in, navigate to your profile settings to change your password to something secure that only you know.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
<p>Hallo <strong>{{username}}</strong>,</p>
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
</ul>
</div>
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Benachrichtigung uber Passwortzurucksetzung
Hallo {{username}},
Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
Ihre neuen Anmeldedaten:
- Benutzername: {{username}}
- Vorlaufiges Passwort: {{new_password}}
SICHERHEITSHINWEIS:
- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.
- Teilen Sie Ihr Passwort niemals mit anderen.
- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.
Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}}
Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['username', 'new_password', 'admin_login_url'])
});
}
};
exports.down = async function(knex) {
// Remove the admin email templates
await knex('email_templates')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset'])
.delete();
};
@@ -0,0 +1,23 @@
/**
* Migration: Add created_by column to events table
* This allows filtering events by owner for role-based access control
*/
exports.up = async function(knex) {
// Add created_by column to events table
await knex.schema.alterTable('events', (table) => {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
});
// Set existing events to be owned by the first admin (super_admin)
const superAdmin = await knex('admin_users').where('role_id', 1).first();
if (superAdmin) {
await knex('events').update({ created_by: superAdmin.id });
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('created_by');
});
};
@@ -0,0 +1,107 @@
/**
* Migration: Add event_types table
*
* Creates a customizable event types system to replace hardcoded event types.
* This allows users to:
* - Rename existing event types (wedding, birthday, corporate, other)
* - Create custom event types with custom slug prefixes
* - Associate default theme presets with event types
*
* Backward compatible: Existing events keep their event_type strings.
* New events can use either legacy strings or custom event type slug_prefix.
*/
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Creating event_types table...');
// Create event_types table
const hasEventTypesTable = await knex.schema.hasTable('event_types');
if (!hasEventTypesTable) {
await knex.schema.createTable('event_types', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
table.string('emoji', 10); // Icon emoji: "👨‍👩‍👧"
table.string('theme_preset', 50); // Default theme: "elegantWedding"
table.text('theme_config'); // Custom theme JSON overrides (optional)
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
table.boolean('is_system').defaultTo(false); // Protect default types
table.boolean('is_active').defaultTo(true); // Allow hiding types
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes for performance
table.index('slug_prefix');
table.index('display_order');
table.index('is_active');
});
console.log('event_types table created');
} else {
console.log('event_types table already exists, skipping creation');
}
// Seed default event types (matching current hardcoded values)
const existingTypes = await knex('event_types').select('slug_prefix');
const existingSlugs = existingTypes.map(t => t.slug_prefix);
const defaultTypes = [
{
name: 'Wedding',
slug_prefix: 'wedding',
emoji: '💒',
theme_preset: 'elegantWedding',
display_order: 1,
is_system: true,
is_active: true
},
{
name: 'Birthday',
slug_prefix: 'birthday',
emoji: '🎂',
theme_preset: 'birthdayFun',
display_order: 2,
is_system: true,
is_active: true
},
{
name: 'Corporate',
slug_prefix: 'corporate',
emoji: '🏢',
theme_preset: 'corporateTimeline',
display_order: 3,
is_system: true,
is_active: true
},
{
name: 'Other',
slug_prefix: 'other',
emoji: '📸',
theme_preset: 'default',
display_order: 4,
is_system: true,
is_active: true
}
];
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
if (typesToInsert.length > 0) {
await knex('event_types').insert(typesToInsert);
console.log(`Inserted ${typesToInsert.length} default event types`);
} else {
console.log('Default event types already exist, skipping seed');
}
console.log('Migration 061_add_event_types_table completed successfully');
};
exports.down = async function(knex) {
console.log('Rolling back event_types table...');
// Drop the table (data will be lost)
await knex.schema.dropTableIfExists('event_types');
console.log('event_types table dropped');
};
@@ -0,0 +1,53 @@
/**
* Migration: Add optional event date and expiration settings
* These settings control whether event_date and expiration are required
* when creating new events, supporting non-event use cases like portraits.
*/
exports.up = async function(knex) {
// Add new settings for optional date and expiration
const settings = [
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
// Make event_date and expires_at columns nullable
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
// SQLite requires table recreation (handled differently)
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// PostgreSQL: directly alter columns
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite: columns are already effectively nullable in most cases
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
// For safety, we'll skip the schema change for SQLite as it's complex
// The application logic will handle null values appropriately
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
}
};
exports.down = async function(knex) {
// Remove the settings
await knex('app_settings')
.whereIn('setting_key', [
'event_require_event_date',
'event_require_expiration'
])
.del();
// Note: We don't restore NOT NULL constraints as that could fail
// if there are existing NULL values in the database
};
@@ -0,0 +1,28 @@
/**
* Migration 061: Add pre-generated watermark path to photos table
* - photos.watermark_path: path to pre-generated watermarked image
* - photos.watermark_generated_at: timestamp of watermark generation
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 061_add_watermark_path');
// photos.watermark_path (nullable - path to pre-generated watermarked image)
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
table.string('watermark_path', 512);
});
// photos.watermark_generated_at (nullable - when watermark was last generated)
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
table.timestamp('watermark_generated_at');
});
console.log('Migration 061_add_watermark_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 061_add_watermark_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,70 @@
/**
* Migration: Add hero logo customization settings to events table
*
* Allows per-event customization of the hero gallery logo:
* - hero_logo_visible: Show/hide the logo overlay
* - hero_logo_size: Logo size (small, medium, large, xlarge)
* - hero_logo_position: Logo position (top, center, bottom)
*
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
*/
exports.up = async function (knex) {
console.log('Adding hero logo settings to events table...');
// Add hero_logo_visible column
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (!hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
});
console.log('Added hero_logo_visible column');
}
// Add hero_logo_size column
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (!hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
});
console.log('Added hero_logo_size column');
}
// Add hero_logo_position column
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (!hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
});
console.log('Added hero_logo_position column');
}
console.log('Migration 062_add_hero_logo_settings completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back hero logo settings...');
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_visible');
});
}
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_size');
});
}
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_position');
});
}
console.log('Hero logo settings columns dropped');
};
@@ -0,0 +1,23 @@
/**
* Migration 062: Add original_filename to photos table
* - photos.original_filename: preserves the original filename from upload
* This enables Lightroom integration by exporting filtered filenames
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 062_add_original_filename');
// photos.original_filename (nullable - original filename before renaming)
await addColumnIfNotExists(knex, 'photos', 'original_filename', (table) => {
table.string('original_filename', 512);
});
console.log('Migration 062_add_original_filename completed');
};
exports.down = async function(knex) {
console.log('Rollback: 062_add_original_filename');
// Keep column (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,53 @@
/**
* Migration: Add custom logo support to events table
*
* Allows per-event custom logo that overrides the global branding logo:
* - hero_logo_url: Public path to the uploaded custom logo
* - hero_logo_path: Full filesystem path to the custom logo
*
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
*
* Addresses GitHub Issue #138: Per-event custom logo option
*/
exports.up = async function (knex) {
console.log('Adding custom logo columns to events table...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (!hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_url', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_url column');
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (!hasPathColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_path', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_path column');
}
console.log('Migration 063_add_event_custom_logo completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back custom logo columns...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_url');
});
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (hasPathColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_path');
});
}
console.log('Custom logo columns dropped');
};
@@ -0,0 +1,105 @@
/**
* Migration: Backfill photo dimensions
*
* This migration extracts width/height from existing photos that don't have
* these dimensions stored. This is needed for aspect-ratio-aware layouts
* (masonry, mosaic, justified) to work properly.
*/
const path = require('path');
const fs = require('fs');
exports.up = async function(knex) {
// Check if the width/height columns exist
const hasWidth = await knex.schema.hasColumn('photos', 'width');
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (!hasWidth || !hasHeight) {
console.log('[Migration 064] Width/height columns not found, skipping backfill');
return;
}
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Find photos without dimensions
const photos = await knex('photos')
.whereNull('width')
.orWhereNull('height')
.select('id', 'path', 'filename', 'media_type');
console.log(`[Migration 064] Found ${photos.length} photos without dimensions`);
if (photos.length === 0) {
return;
}
// Import sharp dynamically (only needed during migration)
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.error('[Migration 064] Sharp not available, skipping backfill:', err.message);
return;
}
let updated = 0;
let failed = 0;
for (const photo of photos) {
try {
// Skip videos - they need ffprobe for metadata
if (photo.media_type === 'video') {
continue;
}
// Construct the full file path
let fullPath;
if (photo.path) {
// Path is relative to events/active directory
fullPath = path.join(storagePath, 'events/active', photo.path);
} else {
console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`);
continue;
}
// Check if file exists
if (!fs.existsSync(fullPath)) {
console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`);
failed++;
continue;
}
// Extract dimensions using sharp
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await knex('photos')
.where('id', photo.id)
.update({
width: metadata.width,
height: metadata.height
});
updated++;
if (updated % 50 === 0) {
console.log(`[Migration 064] Updated ${updated} photos...`);
}
} else {
console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`);
failed++;
}
} catch (err) {
console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message);
failed++;
}
}
console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`);
};
exports.down = async function(knex) {
// This migration only adds data, no rollback needed
// We don't want to null out dimensions on rollback as they're still valid
console.log('[Migration 064] Rollback: No action needed (data-only migration)');
};
@@ -0,0 +1,131 @@
/**
* Migration: Add header_style and hero_divider_style columns
*
* This migration decouples the hero header style from gallery layout,
* allowing any combination of header style with any layout type.
*/
exports.up = async function(knex) {
console.log('[Migration 065] Adding header_style and hero_divider_style columns');
// Check if columns already exist
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (!hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('header_style', 20).defaultTo('standard');
});
console.log('[Migration 065] Added header_style column');
}
if (!hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('hero_divider_style', 20).defaultTo('wave');
});
console.log('[Migration 065] Added hero_divider_style column');
}
// Migrate existing events with hero layout in color_theme
console.log('[Migration 065] Migrating existing hero layouts...');
const events = await knex('events')
.whereNotNull('color_theme')
.select('id', 'color_theme');
let migratedCount = 0;
for (const event of events) {
try {
// Skip if color_theme is not JSON
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Check if this event uses hero layout
if (theme.galleryLayout === 'hero') {
// Migrate: set headerStyle to 'hero' and galleryLayout to 'grid'
const updatedTheme = {
...theme,
headerStyle: 'hero',
galleryLayout: 'grid',
heroDividerStyle: theme.heroDividerStyle || 'wave'
};
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(updatedTheme),
header_style: 'hero',
hero_divider_style: theme.heroDividerStyle || 'wave'
});
migratedCount++;
}
} catch (err) {
// Invalid JSON in color_theme, skip
console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`);
}
}
console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`);
console.log('[Migration 065] Completed');
};
exports.down = async function(knex) {
console.log('[Migration 065] Removing header_style and hero_divider_style columns');
// First, migrate any hero header styles back to hero layout
const events = await knex('events')
.where('header_style', 'hero')
.whereNotNull('color_theme')
.select('id', 'color_theme');
for (const event of events) {
try {
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Revert: set galleryLayout back to 'hero'
const revertedTheme = {
...theme,
galleryLayout: 'hero'
};
// Remove the new properties
delete revertedTheme.headerStyle;
delete revertedTheme.heroDividerStyle;
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(revertedTheme)
});
} catch (err) {
console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`);
}
}
// Remove the columns
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('header_style');
});
}
if (hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('hero_divider_style');
});
}
console.log('[Migration 065] Rollback completed');
};
@@ -0,0 +1,48 @@
/**
* Migration: Add hero image anchor position and category-specific hero images
*
* Issue #162: Add hero_image_anchor column to events table for controlling
* how hero images are cropped (top/center/bottom)
*
* Issue #163: Add hero_photo_id column to photo_categories table for
* category-specific hero images
*/
exports.up = async function(knex) {
// Add hero_image_anchor to events table (Issue #162)
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
// Values: 'top', 'center', 'bottom' - defaults to 'center' for backward compatibility
table.string('hero_image_anchor', 10).defaultTo('center');
});
console.log('Added hero_image_anchor column to events table');
}
// Add hero_photo_id to photo_categories table (Issue #163)
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (!hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
console.log('Added hero_photo_id column to photo_categories table');
}
};
exports.down = async function(knex) {
// Remove hero_image_anchor from events table
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('hero_image_anchor');
});
}
// Remove hero_photo_id from photo_categories table
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.dropColumn('hero_photo_id');
});
}
};
@@ -0,0 +1,32 @@
/**
* Migration: Expand hero_image_anchor column to support focal point percentages
*
* Changes string(10) to string(20) so values like "100% 100%" (9 chars) fit
* with room to spare. Existing 'top', 'center', 'bottom' values are preserved.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
// Column doesn't exist yet nothing to expand
return;
}
// SQLite doesn't truly support ALTER COLUMN, but Knex handles the
// rebuild-table strategy internally when we call alterTable.
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 20).defaultTo('center').alter();
});
console.log('Expanded hero_image_anchor column to string(20)');
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
return;
}
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 10).defaultTo('center').alter();
});
};
@@ -0,0 +1,54 @@
const DEFAULT_AI_AGENTS = [
'GPTBot',
'ChatGPT-User',
'Google-Extended',
'Claude-Web',
'Anthropic-AI',
'CCBot',
'Bytespider',
'FacebookBot',
'Omgilibot',
'Diffbot',
'PetalBot',
'Amazonbot',
'PerplexityBot',
'YouBot',
'Applebot-Extended'
];
exports.up = async function(knex) {
const defaults = [
{ setting_key: 'seo_allow_indexing', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_block_ai_crawlers', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_block_social_bots', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_blocked_ai_agents', setting_value: JSON.stringify(DEFAULT_AI_AGENTS), setting_type: 'seo' },
{ setting_key: 'seo_custom_rules', setting_value: JSON.stringify([]), setting_type: 'seo' },
{ setting_key: 'seo_meta_noindex', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_meta_nofollow', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_meta_noai', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_sitemap_url', setting_value: JSON.stringify(''), setting_type: 'seo' }
];
for (const setting of defaults) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
};
exports.down = async function(knex) {
await knex('app_settings')
.whereIn('setting_key', [
'seo_allow_indexing',
'seo_block_ai_crawlers',
'seo_block_social_bots',
'seo_blocked_ai_agents',
'seo_custom_rules',
'seo_meta_noindex',
'seo_meta_nofollow',
'seo_meta_noai',
'seo_sitemap_url'
])
.del();
};
@@ -0,0 +1,22 @@
/**
* Migration 069: Add hero image path to photos table
* - photos.hero_path: path to hero-optimized image (1920x1080) for gallery headers
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 069_add_hero_path');
// photos.hero_path (nullable - path to hero-optimized image)
await addColumnIfNotExists(knex, 'photos', 'hero_path', (table) => {
table.string('hero_path', 512);
});
console.log('Migration 069_add_hero_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 069_add_hero_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,161 @@
/**
* Migration 070: Add update notification settings and email template
* - Settings for email notifications when new versions are available
* - Email template for version update notifications
*/
exports.up = async function(knex) {
console.log('Running migration: 070_add_update_notification_settings');
// Add app_settings for update notifications
const settings = [
{
setting_key: 'update_email_notifications_enabled',
setting_value: JSON.stringify(false),
setting_type: 'notifications'
},
{
setting_key: 'update_email_recipients',
setting_value: JSON.stringify(''), // Comma-separated emails, or empty for all admin emails
setting_type: 'notifications'
},
{
setting_key: 'last_notified_version',
setting_value: JSON.stringify(''),
setting_type: 'notifications'
}
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
// Check if email template already exists
const existingTemplate = await knex('email_templates')
.where('template_key', 'version_update_available')
.first();
if (!existingTemplate) {
await knex('email_templates').insert({
template_key: 'version_update_available',
subject_en: 'PicPeak Update Available: Version {{new_version}}',
subject_de: 'PicPeak Update verfugbar: Version {{new_version}}',
body_html_en: `
<h2>A New Version of PicPeak is Available</h2>
<p>Great news! A new version of PicPeak is available for your installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Current Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>New Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
</div>
<h3>What's New?</h3>
<p>Check the release notes to see what's included in this update:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">View Release Notes</a>
</div>
<h3>How to Update</h3>
<p>To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Reminder:</strong> Always backup your database before updating to ensure you can recover if anything goes wrong.</p>
</div>
<p>Best regards,<br>
Your PicPeak Installation</p>`,
body_text_en: `A New Version of PicPeak is Available
Great news! A new version of PicPeak is available for your installation.
Current Version: {{current_version}}
New Version: {{new_version}}
Channel: {{channel}}
What's New?
Check the release notes to see what's included in this update:
{{release_notes_url}}
How to Update
To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong.
Best regards,
Your PicPeak Installation`,
body_html_de: `
<h2>Eine neue Version von PicPeak ist verfugbar</h2>
<p>Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Aktuelle Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Neue Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
</div>
<h3>Was ist neu?</h3>
<p>Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Versionshinweise anzeigen</a>
</div>
<h3>So aktualisieren Sie</h3>
<p>Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Erinnerung:</strong> Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.</p>
</div>
<p>Mit freundlichen Grussen,<br>
Ihre PicPeak-Installation</p>`,
body_text_de: `Eine neue Version von PicPeak ist verfugbar
Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
Aktuelle Version: {{current_version}}
Neue Version: {{new_version}}
Kanal: {{channel}}
Was ist neu?
Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
{{release_notes_url}}
So aktualisieren Sie
Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
Mit freundlichen Grussen,
Ihre PicPeak-Installation`,
variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url'])
});
}
console.log('Migration 070_add_update_notification_settings completed');
};
exports.down = async function(knex) {
console.log('Rollback: 070_add_update_notification_settings');
// Remove settings
await knex('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.del();
// Remove email template
await knex('email_templates')
.where('template_key', 'version_update_available')
.del();
};
@@ -0,0 +1,42 @@
/**
* Migration 071: Add captured_at column to photos table
* - Stores the original capture date from EXIF metadata
* - Enables sorting photos by capture date instead of upload date
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 071_add_captured_at');
// Add captured_at column to photos table
await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => {
table.datetime('captured_at').nullable();
});
// Add index for sorting performance
const indexExists = await knex.schema.hasIndex
? await knex.schema.hasIndex('photos', 'idx_photos_captured_at')
: false;
if (!indexExists) {
// Use raw query for index creation with IF NOT EXISTS
const client = knex.client.config.client;
if (client === 'pg') {
await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first
const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'");
if (existingIndexes.length === 0) {
await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)');
}
}
}
console.log('Migration 071_add_captured_at completed');
};
exports.down = async function(knex) {
console.log('Rollback: 071_add_captured_at');
// Keep column for safe rollback (intentionally no-op)
};
@@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.string('host_email', 255).nullable().alter();
table.string('admin_email', 255).nullable().alter();
});
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.string('host_email', 255).notNullable().defaultTo('').alter();
table.string('admin_email', 255).notNullable().defaultTo('').alter();
});
};
@@ -0,0 +1,13 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 074_add_photo_cap');
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
table.integer('photo_cap').nullable().defaultTo(null);
});
console.log('Migration 074_add_photo_cap completed');
};
exports.down = async function(knex) {
console.log('Rollback: 074_add_photo_cap');
};
@@ -0,0 +1,28 @@
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
exports.up = async function(knex) {
// Add visibility column to photos table
await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => {
table.string('visibility', 20).defaultTo('visible').notNullable();
});
// Add client access columns to events table
await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => {
table.boolean('client_access_enabled').defaultTo(false);
});
await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => {
table.string('client_password_hash', 255).nullable();
});
await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => {
table.string('client_share_token', 64).nullable().unique();
});
// Index for filtering photos by visibility
await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility');
};
exports.down = async function(knex) {
// Safe rollback - intentionally no-op to avoid data loss
};
@@ -0,0 +1,281 @@
/**
* Migration to create email_template_translations table
* Moves from per-column language support (subject_en, subject_de) to a
* normalized translations table where each language is a row.
* This allows adding new languages without schema changes.
*/
exports.up = async function(knex) {
// 1. Create the email_template_translations table
await knex.schema.createTable('email_template_translations', (table) => {
table.increments('id').primary();
table.integer('template_id').unsigned().notNullable()
.references('id').inTable('email_templates').onDelete('CASCADE');
table.string('language', 10).notNullable();
table.text('subject');
table.text('body_html');
table.text('body_text');
table.datetime('created_at').defaultTo(knex.fn.now());
table.datetime('updated_at').defaultTo(knex.fn.now());
table.unique(['template_id', 'language']);
});
console.log('Created email_template_translations table');
// 2. Migrate existing data from email_templates columns into rows
const templates = await knex('email_templates').select('*');
const columnInfo = await knex('email_templates').columnInfo();
const hasLangColumns = !!columnInfo.subject_en;
for (const template of templates) {
// Extract EN translation
const enSubject = hasLangColumns
? (template.subject_en || template.subject || '')
: (template.subject || '');
const enHtml = hasLangColumns
? (template.body_html_en || template.body_html || '')
: (template.body_html || '');
const enText = hasLangColumns
? (template.body_text_en || template.body_text || '')
: (template.body_text || '');
// Insert EN translation
if (enSubject || enHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'en',
subject: enSubject,
body_html: enHtml,
body_text: enText,
created_at: new Date(),
updated_at: new Date(),
});
}
// Extract DE translation (only if lang columns exist)
if (hasLangColumns) {
const deSubject = template.subject_de || '';
const deHtml = template.body_html_de || '';
const deText = template.body_text_de || '';
// Only insert if DE content differs from EN or has content
if (deSubject || deHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'de',
subject: deSubject,
body_html: deHtml,
body_text: deText,
created_at: new Date(),
updated_at: new Date(),
});
}
}
}
console.log(`Migrated ${templates.length} templates to translations table`);
// 3. Seed NL, PT, RU translations for customer-facing templates
// Look up template IDs
const customerTemplates = await knex('email_templates')
.whereIn('template_key', [
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
])
.select('id', 'template_key');
const templateMap = {};
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
const seedTranslations = [];
// --- gallery_created ---
if (templateMap.gallery_created) {
const id = templateMap.gallery_created;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij is klaar!',
body_html: `<h2>Galerij succesvol aangemaakt</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
<p><strong>Galerij details:</strong></p>
<ul>
<li>Evenementdatum: {{event_date}}</li>
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Wachtwoord: {{gallery_password}}</li>
<li>Verloopt op: {{expiry_date}}</li>
</ul>
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos está pronta!',
body_html: `<h2>Galeria criada com sucesso</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
<p><strong>Detalhes da galeria:</strong></p>
<ul>
<li>Data do evento: {{event_date}}</li>
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Senha: {{gallery_password}}</li>
<li>Expira em: {{expiry_date}}</li>
</ul>
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
},
{
template_id: id, language: 'ru',
subject: 'Ваша фотогалерея готова!',
body_html: `<h2>Галерея успешно создана</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
<p><strong>Детали галереи:</strong></p>
<ul>
<li>Дата события: {{event_date}}</li>
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Пароль: {{gallery_password}}</li>
<li>Срок действия: {{expiry_date}}</li>
</ul>
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
},
);
}
// --- expiration_warning ---
if (templateMap.expiration_warning) {
const id = templateMap.expiration_warning;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij verloopt binnenkort',
body_html: `<h2>Galerij verloopt binnenkort</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos expira em breve',
body_html: `<h2>Galeria expirando em breve</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия вашей фотогалереи скоро истекает',
body_html: `<h2>Срок действия галереи истекает</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
},
);
}
// --- gallery_expired ---
if (templateMap.gallery_expired) {
const id = templateMap.gallery_expired;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij {{event_name}} is verlopen',
body_html: `<h2>Galerij verlopen</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos {{event_name}} expirou',
body_html: `<h2>Galeria expirada</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия фотогалереи {{event_name}} истёк',
body_html: `<h2>Срок действия галереи истёк</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
},
);
}
// --- archive_complete ---
if (templateMap.archive_complete) {
const id = templateMap.archive_complete;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Archivering voltooid: {{event_name}}',
body_html: `<h2>Archivering voltooid</h2>
<p>Beste {{host_name}},</p>
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
<p><strong>Archief details:</strong></p>
<ul>
<li>Aantal foto's: {{photo_count}}</li>
<li>Archiefgrootte: {{archive_size}}</li>
<li>Archiefdatum: {{archive_date}}</li>
</ul>`,
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
},
{
template_id: id, language: 'pt',
subject: 'Arquivamento concluído: {{event_name}}',
body_html: `<h2>Arquivamento concluído</h2>
<p>Prezado(a) {{host_name}},</p>
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
<p><strong>Detalhes do arquivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamanho do arquivo: {{archive_size}}</li>
<li>Data do arquivamento: {{archive_date}}</li>
</ul>`,
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
},
{
template_id: id, language: 'ru',
subject: 'Архивация завершена: {{event_name}}',
body_html: `<h2>Архивация завершена</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
<p><strong>Детали архива:</strong></p>
<ul>
<li>Количество фото: {{photo_count}}</li>
<li>Размер архива: {{archive_size}}</li>
<li>Дата архивации: {{archive_date}}</li>
</ul>`,
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
},
);
}
// Insert all seed translations
const now = new Date();
for (const trans of seedTranslations) {
trans.created_at = now;
trans.updated_at = now;
await knex('email_template_translations').insert(trans);
}
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('email_template_translations');
};
@@ -0,0 +1,21 @@
/**
* Migration to add is_draft column to events table.
* Draft events are not visible to gallery visitors until published.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.boolean('is_draft').defaultTo(false);
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('is_draft');
});
}
};
@@ -0,0 +1,17 @@
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.string('default_photo_sort', 50).defaultTo('upload_date_desc');
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('default_photo_sort');
});
}
};
@@ -0,0 +1,120 @@
/**
* Add guest identity layer for per-person photo selections (issue #292).
*
* Adds:
* - gallery_guests — persistent guest profiles per event
* - guest_invites — pre-minted invite tokens (Phase 3.3)
* - guest_verification_codes — email-based identity recovery (Phase 3.2)
* - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple')
* - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable)
*
* All changes are additive. Existing events default to 'simple' mode so behavior
* is unchanged. Legacy photo_feedback rows keep NULL guest_id.
*/
exports.up = async function(knex) {
// 1. gallery_guests — persistent per-person identity within an event.
const hasGalleryGuests = await knex.schema.hasTable('gallery_guests');
if (!hasGalleryGuests) {
await knex.schema.createTable('gallery_guests', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('name', 100).notNullable();
table.string('email', 255);
table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side
table.string('ip_address_last', 45);
table.text('user_agent_last');
table.timestamp('email_verified_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('last_seen_at').defaultTo(knex.fn.now());
table.boolean('is_deleted').defaultTo(false);
table.unique(['event_id', 'identifier']);
table.index(['event_id']);
table.index(['event_id', 'email']);
});
}
// 2. guest_invites — pre-minted one-time-use tokens for invited guests.
const hasGuestInvites = await knex.schema.hasTable('guest_invites');
if (!hasGuestInvites) {
await knex.schema.createTable('guest_invites', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE');
table.string('token', 64).notNullable().unique();
table.integer('created_by_admin_id').references('id').inTable('admin_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('redeemed_at');
table.timestamp('revoked_at');
table.index(['event_id']);
table.index(['guest_id']);
});
}
// 3. guest_verification_codes — short-lived codes for email-based recovery.
const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes');
if (!hasGuestVerificationCodes) {
await knex.schema.createTable('guest_verification_codes', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('email', 255).notNullable();
table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code
table.integer('attempts').defaultTo(0);
table.timestamp('expires_at').notNullable();
table.timestamp('consumed_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['event_id', 'email']);
table.index(['expires_at']);
});
}
// 4. event_feedback_settings.identity_mode
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (!hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.string('identity_mode', 16).notNullable().defaultTo('simple');
});
if (knex.client.config.client === 'pg') {
await knex.raw(`
ALTER TABLE event_feedback_settings
ADD CONSTRAINT event_feedback_settings_identity_mode_check
CHECK (identity_mode IN ('simple','guest'))
`);
}
}
// 5. photo_feedback.guest_id FK
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (!hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL');
table.index(['guest_id']);
});
}
};
exports.down = async function(knex) {
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('guest_id');
});
}
if (knex.client.config.client === 'pg') {
await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check');
}
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('identity_mode');
});
}
await knex.schema.dropTableIfExists('guest_verification_codes');
await knex.schema.dropTableIfExists('guest_invites');
await knex.schema.dropTableIfExists('gallery_guests');
};
@@ -0,0 +1,26 @@
/**
* Add download ZIP cache columns to events table.
*
* Enables pre-generated ZIP files for "Download All" so guests get
* instant downloads with Content-Length instead of on-the-fly streaming.
*/
exports.up = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (!hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.text('download_zip_path').nullable().defaultTo(null);
table.datetime('download_zip_generated_at').nullable().defaultTo(null);
});
}
};
exports.down = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('download_zip_path');
table.dropColumn('download_zip_generated_at');
});
}
};
@@ -0,0 +1,33 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* #322 — optional phone-number field on events. Off by default; surfaced
* only when the global `event_phone_field_enabled` app setting is true,
* so existing deployments see no UI change unless the admin opts in.
*/
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => {
table.string('customer_phone', 32).nullable();
});
// Seed the global enable flag (default false).
const exists = await knex('app_settings')
.where('setting_key', 'event_phone_field_enabled')
.first();
if (!exists) {
await knex('app_settings').insert({
setting_key: 'event_phone_field_enabled',
setting_value: JSON.stringify(false),
setting_type: 'boolean'
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasColumn('events', 'customer_phone')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_phone');
});
}
await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete();
};
@@ -0,0 +1,41 @@
/**
* #322 — long-lived API tokens for programmatic access (n8n, custom
* integrations, external apps). Each token belongs to an admin user; the
* token's effective permissions are the *intersection* of the user's
* role permissions and the token's own scope flags. That way revoking
* the user revokes the token, and scope flags let an admin issue a
* read-only token even if their account is super_admin.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('api_tokens'))) {
await knex.schema.createTable('api_tokens', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
// SHA-256 of the full token string (`pp_live_<random>`). Lookup
// hashes the incoming Authorization header and queries by this.
table.string('hashed_token', 64).notNullable().unique();
// Scope flags — comma-separated subset of: read, write, admin.
// 'read' allows GETs; 'write' adds POST/PATCH/DELETE on
// event/photo data; 'admin' allows creating/deleting events and
// anything else gated by admin.* permissions.
table.string('scopes', 64).notNullable().defaultTo('read');
table.integer('created_by').notNullable()
.references('id').inTable('admin_users').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('expires_at').nullable();
table.timestamp('last_used_at').nullable();
table.timestamp('revoked_at').nullable();
// Cosmetic for the admin UI: first 8 chars of the plaintext
// token (after the prefix) so admins can identify which token is
// which without seeing the secret half.
table.string('preview', 16).nullable();
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasTable('api_tokens')) {
await knex.schema.dropTable('api_tokens');
}
};
@@ -0,0 +1,80 @@
/**
* #327 — outbound webhooks (push API) for the event/photo lifecycle.
*
* Two tables:
* webhooks — admin-managed subscriptions (URL + events + secret)
* webhook_deliveries — single source of truth for the delivery worker
* (audit log + retry queue in one).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('webhooks'))) {
await knex.schema.createTable('webhooks', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
// Validated via networkValidation.validateExternalUrl on create + per
// delivery (DNS-rebinding mitigation).
table.string('url', 2048).notNullable();
// Plaintext signing secret (`whsec_<random>`). Stored unencrypted
// because we need to recompute HMAC-SHA256 over every outbound body
// — a hash would make the secret unrecoverable. Same posture as
// SMTP passwords stored in app_settings; protect the DB. The
// plaintext is also returned to the admin once on create so they can
// configure the receiver to verify signatures.
table.string('secret', 100).notNullable();
// First 8 chars of the secret for the admin UI so operators can
// tell which webhook is which without revealing the full secret.
table.string('secret_preview', 16).nullable();
// JSON array of subscribed event types
// (e.g. ["event.published","photo.uploaded"]).
table.jsonb('events').notNullable().defaultTo('[]');
table.boolean('active').notNullable().defaultTo(true);
table.integer('created_by').notNullable()
.references('id').inTable('admin_users').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.timestamp('last_success_at').nullable();
table.timestamp('last_failure_at').nullable();
// Index for the delivery worker's "find subscriptions for this event"
// query — small set, but keeps the lookup constant-time as it grows.
table.index('active', 'webhooks_active_idx');
});
}
if (!(await knex.schema.hasTable('webhook_deliveries'))) {
await knex.schema.createTable('webhook_deliveries', (table) => {
table.increments('id').primary();
table.integer('webhook_id').notNullable()
.references('id').inTable('webhooks').onDelete('CASCADE');
table.string('event_type', 64).notNullable();
// Full signed payload (the JSON body that was POSTed).
table.jsonb('payload').notNullable();
table.integer('attempt_count').notNullable().defaultTo(0);
// pending → success | failed. pending rows with next_retry_at <= NOW()
// are picked up by the worker.
table.string('status', 16).notNullable().defaultTo('pending');
table.integer('response_status').nullable();
// Truncated to 1KB before storage so a verbose receiver can't blow
// up the row size.
table.text('response_body').nullable();
table.text('last_error').nullable();
table.integer('latency_ms').nullable();
table.timestamp('next_retry_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('completed_at').nullable();
// Worker hot-path query: WHERE status='pending' AND next_retry_at <= NOW()
// ORDER BY next_retry_at LIMIT N. This composite index serves it directly.
table.index(['status', 'next_retry_at'], 'webhook_deliveries_status_retry_idx');
table.index('webhook_id', 'webhook_deliveries_webhook_idx');
});
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasTable('webhook_deliveries')) {
await knex.schema.dropTable('webhook_deliveries');
}
if (await knex.schema.hasTable('webhooks')) {
await knex.schema.dropTable('webhooks');
}
};
@@ -0,0 +1,49 @@
/**
* Adds:
* - events.allow_presigned_download — per-event opt-in for the
* presigned-URL "Download All" path (#328 follow-up). Off by default
* because it bypasses watermarks; admins flip it knowingly.
* - webhooks.filter — JSONB predicate evaluated against the payload at
* fire time (#327 follow-up). Empty object = no filter, fire always.
* - webhooks.template — optional ${dot.path} string template applied
* to the request body before signing. NULL = use the default JSON
* envelope (back-compat).
*/
exports.up = async function up(knex) {
if (await knex.schema.hasTable('events')) {
const hasCol = await knex.schema.hasColumn('events', 'allow_presigned_download');
if (!hasCol) {
await knex.schema.alterTable('events', (table) => {
table.boolean('allow_presigned_download').notNullable().defaultTo(false);
});
}
}
if (await knex.schema.hasTable('webhooks')) {
const hasFilter = await knex.schema.hasColumn('webhooks', 'filter');
if (!hasFilter) {
await knex.schema.alterTable('webhooks', (table) => {
table.jsonb('filter').notNullable().defaultTo('{}');
});
}
const hasTemplate = await knex.schema.hasColumn('webhooks', 'template');
if (!hasTemplate) {
await knex.schema.alterTable('webhooks', (table) => {
table.text('template').nullable();
});
}
}
};
exports.down = async function down(knex) {
if (await knex.schema.hasColumn('webhooks', 'template')) {
await knex.schema.alterTable('webhooks', (t) => t.dropColumn('template'));
}
if (await knex.schema.hasColumn('webhooks', 'filter')) {
await knex.schema.alterTable('webhooks', (t) => t.dropColumn('filter'));
}
if (await knex.schema.hasColumn('events', 'allow_presigned_download')) {
await knex.schema.alterTable('events', (t) => t.dropColumn('allow_presigned_download'));
}
};
@@ -0,0 +1,21 @@
/**
* Heal events whose hero_logo_position contains a branding-style value
* (left/right) caused by a prior bug in adminEvents.js getBrandingDefaults
* that mapped branding_logo_position (left/center/right) onto
* hero_logo_position (top/center/bottom). Any non-canonical value is
* reset to 'top' so subsequent PUTs no longer fail validation.
*/
exports.up = async function up(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (!hasColumn) return;
await knex('events')
.whereNotIn('hero_logo_position', ['top', 'center', 'bottom'])
.update({ hero_logo_position: 'top' });
};
exports.down = async function down() {
// Data correction is not reversible — the original (incorrect) values
// are not preserved.
};
@@ -0,0 +1,72 @@
/**
* Async photo-processing infrastructure.
*
* Adds:
* - photos.processing_status — enum: pending | processing | complete | failed
* - photos.processing_error — text, populated on 'failed'
* - photos.processing_started_at — claim timestamp for janitor recovery
* - photos.upload_id — groups all photos from one upload request
* so the frontend can poll/stream by group
*
* All existing rows default to 'complete' (they were processed synchronously
* before this migration and there's nothing pending). New uploads insert
* with 'pending' and a background worker (services/backgroundProcessor.js)
* picks them up.
*
* Partial-style indexes keep lookups fast as the queue drains. We use plain
* indexes here instead of postgres-specific WHERE clauses so the migration
* works on SQLite too; the workload (only-pending rows) keeps the index small.
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
const hasStatus = await knex.schema.hasColumn('photos', 'processing_status');
if (!hasStatus) {
await knex.schema.alterTable('photos', (table) => {
table.string('processing_status', 16).notNullable().defaultTo('complete');
table.text('processing_error').nullable();
table.timestamp('processing_started_at').nullable();
table.string('upload_id', 64).nullable();
});
}
// Indexes — wrap in try/catch so re-running the migration on a partially
// applied schema is a no-op rather than an error.
try {
await knex.schema.alterTable('photos', (table) => {
table.index(['processing_status'], 'idx_photos_processing_status');
});
} catch (_) { /* already exists */ }
try {
await knex.schema.alterTable('photos', (table) => {
table.index(['upload_id'], 'idx_photos_upload_id');
});
} catch (_) { /* already exists */ }
};
exports.down = async function down(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
// Drop indexes first (best-effort)
try {
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_upload_id'));
} catch (_) { /* not present */ }
try {
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_processing_status'));
} catch (_) { /* not present */ }
if (await knex.schema.hasColumn('photos', 'upload_id')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('upload_id'));
}
if (await knex.schema.hasColumn('photos', 'processing_started_at')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_started_at'));
}
if (await knex.schema.hasColumn('photos', 'processing_error')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_error'));
}
if (await knex.schema.hasColumn('photos', 'processing_status')) {
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_status'));
}
};
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
}
};
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Check which columns already exist to make migration idempotent
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
@@ -1,23 +1,56 @@
exports.up = async function(knex) {
// Add user upload settings to events table
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
// Add user upload settings to events table (check if columns exist first)
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (!hasAllowUserUploads) {
console.log('Adding allow_user_uploads column to events table...');
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
});
} else {
console.log('Column allow_user_uploads already exists in events table, skipping...');
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (!hasUploadCategoryId) {
console.log('Adding upload_category_id column to events table...');
await knex.schema.alterTable('events', function(table) {
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
} else {
console.log('Column upload_category_id already exists in events table, skipping...');
}
// Add uploaded_by field to photos table to track who uploaded
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (!hasUploadedBy) {
console.log('Adding uploaded_by column to photos table...');
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
} else {
console.log('Column uploaded_by already exists in photos table, skipping...');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
table.dropColumn('upload_category_id');
});
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (hasAllowUserUploads) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
});
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (hasUploadCategoryId) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('upload_category_id');
});
}
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (hasUploadedBy) {
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
}
};
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
// PostgreSQL error codes:
// - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename });
// Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`);
}
}
+2325 -1540
View File
File diff suppressed because it is too large Load Diff
+26 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.5",
"version": "3.37.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,7 +8,9 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"lint": "eslint src/"
},
"dependencies": {
@@ -17,34 +19,39 @@
"@aws-sdk/s3-request-presigner": "^3.850.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.12.2",
"axios": "1.14.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"handlebars": "^4.7.8",
"handlebars": "^4.7.9",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "^7.0.10",
"nodemailer": "^7.0.13",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -55,5 +62,19 @@
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10",
"supertest": "^6.3.3"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
},
"glob": "^11.1.0",
"js-yaml": "^4.1.1",
"fast-xml-parser": ">=5.5.10",
"qs": ">=6.14.2",
"tar": ">=7.5.13",
"brace-expansion": ">=5.0.5",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1"
}
}
Executable → Regular
View File
View File
Executable → Regular
+29 -18
View File
@@ -44,28 +44,39 @@ async function createAdmin() {
.orWhere('username', username)
.first();
if (existingUser) {
console.error(`Error: User with email "${email}" or username "${username}" already exists`);
process.exit(1);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create admin user
await db('admin_users').insert({
username,
email,
password_hash: passwordHash,
is_active: true,
created_at: new Date(),
updated_at: new Date()
});
if (existingUser) {
// Update existing user's password
await db('admin_users')
.where('id', existingUser.id)
.update({
password_hash: passwordHash,
updated_at: new Date()
});
console.log(`✅ Admin user created successfully!`);
console.log(` Email: ${email}`);
console.log(` Username: ${username}`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
console.log(`✅ Admin user updated successfully!`);
console.log(` Email: ${existingUser.email}`);
console.log(` Username: ${existingUser.username}`);
console.log(` Password has been reset to the provided value`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
} else {
// Create new admin user
await db('admin_users').insert({
username,
email,
password_hash: passwordHash,
is_active: true,
created_at: new Date(),
updated_at: new Date()
});
console.log(`✅ Admin user created successfully!`);
console.log(` Email: ${email}`);
console.log(` Username: ${username}`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
}
process.exit(0);
} catch (error) {
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/**
* Generate the OpenAPI spec from JSDoc annotations in src/routes/v1/* and
* write it as YAML + JSON to ../docs/. Used by scripts/sync-api-docs.sh
* to keep the picpeak-docs site in lockstep with the running API.
*/
const fs = require('fs');
const path = require('path');
// Need yaml — runtime require so the script fails clearly with an
// install hint instead of an opaque MODULE_NOT_FOUND.
let yaml;
try {
yaml = require('js-yaml');
} catch {
console.error('generate-openapi: missing dependency `js-yaml`. Run `npm install --save-dev js-yaml` in /backend.');
process.exit(2);
}
const { getOpenApiSpec } = require('../src/openapi/spec');
const outDir = path.resolve(__dirname, '../../docs');
fs.mkdirSync(outDir, { recursive: true });
const spec = getOpenApiSpec();
fs.writeFileSync(path.join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2));
fs.writeFileSync(path.join(outDir, 'openapi.yaml'), yaml.dump(spec, { lineWidth: 100 }));
console.log(`Wrote openapi.json + openapi.yaml to ${outDir}`);
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Script to generate pre-watermarked versions for existing photos
* This is a one-time migration script to populate watermarks for photos
* that existed before the pre-generation feature was implemented.
*
* Usage: node scripts/generate-watermarks.js [eventId]
*
* Options:
* eventId - Optional: Only generate watermarks for a specific event
*
* Examples:
* node scripts/generate-watermarks.js # Generate for all photos
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
*/
const path = require('path');
const { db } = require('../src/database/db');
const watermarkService = require('../src/services/watermarkService');
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
async function generateWatermarks(eventId = null) {
try {
console.log('='.repeat(60));
console.log('PicPeak Watermark Generation Script');
console.log('='.repeat(60));
// Check if watermarking is enabled
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
console.log('\nWatermarking is currently DISABLED in settings.');
console.log('Enable watermarking in Admin > Branding settings first.');
console.log('Exiting without generating watermarks.');
process.exit(0);
}
console.log('\nWatermark Settings:');
console.log(` Enabled: ${settings.enabled}`);
console.log(` Position: ${settings.position}`);
console.log(` Opacity: ${settings.opacity}%`);
console.log(` Size: ${settings.size}%`);
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNull('photos.watermark_path')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.id',
'photos.filename',
'photos.event_id',
'events.event_name'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`\nFiltering to event ID: ${eventId}`);
}
const photos = await query;
if (photos.length === 0) {
console.log('\nNo photos found without watermarks.');
if (eventId) {
console.log(`(Checked event ID: ${eventId})`);
}
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
process.exit(0);
}
console.log(`\nFound ${photos.length} photos without watermarks.`);
// Group by event for display
const eventCounts = {};
photos.forEach(p => {
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
});
console.log('\nPhotos by event:');
Object.entries(eventCounts).forEach(([name, count]) => {
console.log(` ${name}: ${count} photos`);
});
console.log('\nStarting watermark generation...\n');
let successCount = 0;
let failCount = 0;
const startTime = Date.now();
// Process photos with progress display
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const progress = Math.round(((i + 1) / photos.length) * 100);
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
try {
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
if (result.success) {
successCount++;
} else {
failCount++;
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
}
} catch (error) {
failCount++;
console.log(`\n Error: ${photo.filename} - ${error.message}`);
}
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n');
console.log('='.repeat(60));
console.log('Watermark Generation Complete');
console.log('='.repeat(60));
console.log(` Total processed: ${photos.length}`);
console.log(` Successful: ${successCount}`);
console.log(` Failed: ${failCount}`);
console.log(` Duration: ${duration} seconds`);
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
if (failCount > 0) {
console.log('\nSome watermarks failed to generate. Check the errors above.');
console.log('You can re-run this script to retry failed photos.');
}
process.exit(failCount > 0 ? 1 : 0);
} catch (error) {
console.error('\nFatal error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const eventId = args[0] ? parseInt(args[0], 10) : null;
if (args[0] && isNaN(eventId)) {
console.error('Error: eventId must be a number');
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
process.exit(1);
}
// Run the script
generateWatermarks(eventId)
.then(() => {
process.exit(0);
})
.catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* migrate-storage.js
*
* One-shot migration tool to copy every PicPeak content file from the local
* filesystem (the legacy STORAGE_PATH) to a configured S3-compatible bucket.
*
* Reads the relative path of each known asset from the database:
* photos.path
* photos.thumbnail_path
* photos.hero_path
* photos.watermark_path
* events.archive_path
* events.download_zip_path
*
* For each, streams from local fs → S3, skipping files whose sha256 already
* matches a previously uploaded object (idempotent — safe to re-run).
*
* Does NOT flip STORAGE_BACKEND. After the migration completes clean, the
* operator updates their environment + restarts the backend explicitly.
*
* Usage:
* node backend/scripts/migrate-storage.js # live migration
* node backend/scripts/migrate-storage.js --dry-run # report only, no uploads
* node backend/scripts/migrate-storage.js --failures-csv=/path/to/failures.csv
* node backend/scripts/migrate-storage.js --concurrency=4
*
* Required env (S3 destination — same vars the backend reads with STORAGE_BACKEND=s3):
* STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ACCESS_KEY, STORAGE_S3_SECRET_KEY
* STORAGE_S3_ENDPOINT (optional — for MinIO/R2/etc.)
* STORAGE_S3_PREFIX (optional)
*
* STORAGE_PATH must point at the live local storage root. Postgres connection
* uses the same DB env vars the backend uses.
*/
require('dotenv').config();
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { db } = require('../src/database/db');
const LocalFsStorage = require('../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../src/services/storage/S3StorageBackend');
const logger = require('../src/utils/logger');
function parseArgs(argv) {
const args = { dryRun: false, concurrency: 4, failuresCsv: '/tmp/migrate-storage-failures.csv' };
for (const arg of argv) {
if (arg === '--dry-run') args.dryRun = true;
else if (arg.startsWith('--concurrency=')) args.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 4);
else if (arg.startsWith('--failures-csv=')) args.failuresCsv = arg.split('=')[1];
else if (arg === '--help' || arg === '-h') {
console.log('Usage: node migrate-storage.js [--dry-run] [--concurrency=N] [--failures-csv=PATH]');
process.exit(0);
}
}
return args;
}
function buildLocalSource() {
const root = process.env.STORAGE_PATH;
if (!root) {
throw new Error('STORAGE_PATH must be set to the local storage root.');
}
return new LocalFsStorage({ root });
}
function buildS3Destination() {
const required = ['STORAGE_S3_BUCKET', 'STORAGE_S3_ACCESS_KEY', 'STORAGE_S3_SECRET_KEY'];
const missing = required.filter((v) => !process.env[v]);
if (missing.length) {
throw new Error(`Missing S3 env vars: ${missing.join(', ')}`);
}
return new S3StorageBackend({
bucket: process.env.STORAGE_S3_BUCKET,
region: process.env.STORAGE_S3_REGION || 'us-east-1',
endpoint: process.env.STORAGE_S3_ENDPOINT,
accessKeyId: process.env.STORAGE_S3_ACCESS_KEY,
secretAccessKey: process.env.STORAGE_S3_SECRET_KEY,
prefix: process.env.STORAGE_S3_PREFIX,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
sslEnabled: process.env.STORAGE_S3_SSL !== 'false',
});
}
async function sha256OfFile(localPath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(localPath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
async function collectKeys() {
const keys = new Map(); // key -> { source, contentType }
const addKey = (key, source) => {
if (!key) return;
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized) return;
if (!keys.has(normalized)) keys.set(normalized, { source });
};
// photos: path (events/active/{slug}/{filename}), thumbnail_path, hero_path, watermark_path
const photoBatch = await db('photos').select('id', 'path', 'thumbnail_path', 'hero_path', 'watermark_path');
for (const p of photoBatch) {
if (p.path) {
const photoKey = p.path.startsWith('events/active/') ? p.path : path.posix.join('events/active', p.path);
addKey(photoKey, `photos.path[${p.id}]`);
}
addKey(p.thumbnail_path, `photos.thumbnail_path[${p.id}]`);
addKey(p.hero_path, `photos.hero_path[${p.id}]`);
addKey(p.watermark_path, `photos.watermark_path[${p.id}]`);
}
// events: archive_path, download_zip_path
const eventBatch = await db('events').select('id', 'archive_path', 'download_zip_path');
for (const e of eventBatch) {
addKey(e.archive_path, `events.archive_path[${e.id}]`);
addKey(e.download_zip_path, `events.download_zip_path[${e.id}]`);
}
return keys;
}
async function migrateOne(key, meta, { source, dest, dryRun }) {
// Source must exist on local disk.
const localPath = source.resolveLocalPath(key);
let localStat;
try {
localStat = await fsp.stat(localPath);
} catch (err) {
if (err.code === 'ENOENT') {
return { key, status: 'missing-locally', source: meta.source };
}
throw err;
}
// Idempotent skip: if S3 already has matching size + sha256.
const remoteStat = await dest.stat(key);
if (remoteStat && remoteStat.size === localStat.size) {
// sha256 match check via metadata is expensive; we trust size match for now.
// Operators paranoid about content drift can `rm` the bucket and re-run.
return { key, status: 'already-uploaded', source: meta.source };
}
if (dryRun) {
return { key, status: 'would-upload', source: meta.source, size: localStat.size };
}
await dest.putFromFile(key, localPath);
const verify = await dest.stat(key);
if (!verify || verify.size !== localStat.size) {
return { key, status: 'size-mismatch-after-upload', source: meta.source, expected: localStat.size, got: verify?.size };
}
return { key, status: 'uploaded', source: meta.source, size: localStat.size };
}
async function processWithConcurrency(items, concurrency, fn) {
const results = [];
let i = 0;
const workers = Array.from({ length: concurrency }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) return;
const [key, meta] = items[idx];
try {
const r = await fn(key, meta);
results.push(r);
} catch (err) {
results.push({ key, status: 'error', source: meta.source, error: err.message });
}
}
});
await Promise.all(workers);
return results;
}
function formatCsvCell(v) {
if (v == null) return '';
const s = String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
async function writeFailuresCsv(filePath, failures) {
if (failures.length === 0) {
// Touch an empty file with header so callers see a deterministic outcome.
await fsp.writeFile(filePath, 'key,source,status,error\n');
return;
}
const lines = ['key,source,status,error'];
for (const f of failures) {
lines.push([f.key, f.source, f.status, f.error || ''].map(formatCsvCell).join(','));
}
await fsp.writeFile(filePath, lines.join('\n') + '\n');
}
async function main() {
const args = parseArgs(process.argv.slice(2));
logger.info(`migrate-storage starting (dry-run=${args.dryRun}, concurrency=${args.concurrency})`);
const source = buildLocalSource();
await source.init();
const dest = buildS3Destination();
await dest.init();
logger.info('collecting key list from database…');
const keys = await collectKeys();
logger.info(`found ${keys.size} unique keys to process`);
const items = Array.from(keys.entries());
const results = await processWithConcurrency(items, args.concurrency, (key, meta) =>
migrateOne(key, meta, { source, dest, dryRun: args.dryRun })
);
const counts = results.reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1;
return acc;
}, {});
console.log('\n=== migrate-storage summary ===');
for (const [status, count] of Object.entries(counts).sort()) {
console.log(` ${status.padEnd(28)} ${count}`);
}
const failureStatuses = new Set(['error', 'missing-locally', 'size-mismatch-after-upload']);
const failures = results.filter((r) => failureStatuses.has(r.status));
await writeFailuresCsv(args.failuresCsv, failures);
if (failures.length > 0) {
console.log(`\nWrote ${failures.length} failures to ${args.failuresCsv}`);
console.log('Re-run with --dry-run to triage; fix sources or remove DB rows that point at missing files.');
process.exitCode = 1;
} else if (args.dryRun) {
console.log(`\nDry-run complete. Re-run without --dry-run to perform the migration.`);
console.log(`(Empty failures CSV written to ${args.failuresCsv}.)`);
} else {
console.log(`\nMigration complete. Update STORAGE_BACKEND=s3 + restart the backend to switch over.`);
}
await db.destroy();
}
main().catch(async (err) => {
console.error('migrate-storage failed:', err);
try { await db.destroy(); } catch (_) { /* ignore */ }
process.exit(2);
});
View File
View File
View File
+103 -7
View File
@@ -1,5 +1,15 @@
#!/usr/bin/env node
/**
* Script to set/reset admin password
*
* Usage:
* node set-admin-password.js <new-password>
* node set-admin-password.js --env (uses ADMIN_PASSWORD environment variable)
*
* Security: Password must be at least 8 characters with mixed case, numbers, and special characters
*/
const bcrypt = require('bcrypt');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
@@ -16,22 +26,108 @@ const db = knex({
}
});
/**
* Validate password strength
*/
function validatePassword(password) {
if (!password || password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters long' };
}
if (!/[a-z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain at least one number' };
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
return { valid: false, error: 'Password must contain at least one special character' };
}
return { valid: true };
}
function printUsage() {
console.log(`
Usage:
node set-admin-password.js <new-password>
node set-admin-password.js --env
Options:
<new-password> The new password to set (must meet security requirements)
--env Use ADMIN_PASSWORD environment variable
Security Requirements:
- At least 8 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)
Examples:
node set-admin-password.js "MySecure@Pass123"
ADMIN_PASSWORD="MySecure@Pass123" node set-admin-password.js --env
`);
}
async function setAdminPassword() {
try {
const password = 'admin123';
const hashedPassword = await bcrypt.hash(password, 10);
await db('admin_users')
// Get password from argument or environment
const args = process.argv.slice(2);
let password;
if (args.length === 0) {
console.error('❌ Error: No password provided\n');
printUsage();
process.exit(1);
}
if (args[0] === '--env') {
password = process.env.ADMIN_PASSWORD;
if (!password) {
console.error('❌ Error: ADMIN_PASSWORD environment variable not set');
process.exit(1);
}
} else if (args[0] === '--help' || args[0] === '-h') {
printUsage();
process.exit(0);
} else {
password = args[0];
}
// Validate password strength
const validation = validatePassword(password);
if (!validation.valid) {
console.error(`❌ Error: ${validation.error}`);
process.exit(1);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Update database
const updated = await db('admin_users')
.where('username', 'admin')
.update({
password_hash: hashedPassword,
password_changed_at: new Date(),
updated_at: new Date()
});
console.log('✅ Admin password set to: admin123');
if (updated === 0) {
console.error('❌ Error: Admin user not found');
process.exit(1);
}
console.log('✅ Admin password updated successfully');
console.log(' Note: All existing sessions have been invalidated');
await db.destroy();
process.exit(0);
} catch (error) {
console.error('❌ Error setting password:', error);
console.error('❌ Error setting password:', error.message);
await db.destroy();
process.exit(1);
}
}

Some files were not shown because too many files have changed in this diff Show More