Compare commits
283 Commits
v2.3.2
...
v3.28.1-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 61142c0d0e | |||
| 623ab72916 | |||
| 3716ff5085 | |||
| dffe057772 | |||
| 3319a304ce | |||
| c303dd51e8 | |||
| b1dfbe4c2f | |||
| 54badefc51 | |||
| 15a8ab41fd | |||
| 77f07e9329 | |||
| 72c0c2d18e | |||
| 95d8bc4065 | |||
| 3856ba25bb | |||
| 9e1ba4f851 | |||
| b0efd32f7a | |||
| 9ed8a2b199 | |||
| ad4e5a7506 | |||
| d4b4dc628f | |||
| fe46e4268d | |||
| 1f3b9c6712 | |||
| 5295516b67 | |||
| ee0baafc59 | |||
| c63bc47089 | |||
| 147dc28440 | |||
| c031b1e863 | |||
| b1d16670d5 | |||
| ba1f010166 | |||
| ad64005a80 | |||
| 8805fa53e6 | |||
| 633d4a0f30 | |||
| b23c51b386 | |||
| 835bdf5abb | |||
| a1b63de251 | |||
| 97b1ae5b03 | |||
| dc98206737 | |||
| 40332a71db | |||
| 125cd0d003 | |||
| 83868ffe2f | |||
| 9ddd50f7e4 | |||
| bec36fc99f | |||
| ea50488e99 | |||
| 8614c2232c | |||
| 07fc5e6519 | |||
| 3c8d344ddd | |||
| edf8bd54af | |||
| 2b7c9b0138 | |||
| aef9b4ed7f | |||
| ee3f6ae13b | |||
| 5025a42bf7 | |||
| 0a7a89045b | |||
| ddefd3a95e | |||
| 978e4473b5 | |||
| 8c5996e4ec | |||
| f50d7c0c51 | |||
| 4ce8dd297a | |||
| 85a4eb90fd | |||
| e32da68cbd | |||
| b54a80d251 | |||
| 2ac6c51fe5 | |||
| 23cd9cb680 | |||
| a63f1a8dd9 | |||
| 954a0118ba | |||
| ee46088985 | |||
| 3742d71535 | |||
| 486239aeb9 | |||
| 2c5ae6fbb9 | |||
| f9889a93fb | |||
| 4a93e4e8cb | |||
| e1b6e43e52 | |||
| 999c66dbbf | |||
| f5997892c4 | |||
| 7ca96315e2 | |||
| f3622396e7 | |||
| 56cf60c570 | |||
| 2618415aa1 | |||
| dfae2c2bc6 | |||
| bbeedd1888 | |||
| 201965b4b1 | |||
| 1468c459ba | |||
| 088de43f09 | |||
| 1fa222e9c4 | |||
| 6aceb40595 | |||
| 431a82eca1 | |||
| 85a07fcca7 | |||
| 1f524f2358 | |||
| 48a025b915 | |||
| c652ae0ead | |||
| 9a6d2e8e3a | |||
| fc0911acf8 | |||
| f77802325a | |||
| 74c9a5fbcd | |||
| 703c03fbee | |||
| 6f95b8c26c | |||
| 7250c427b9 | |||
| 04a7ea80f9 | |||
| c0a5cd56c8 | |||
| 908ab08815 | |||
| 52ab609597 | |||
| fafcfbf4e6 | |||
| f07602553c | |||
| 56f497c5f1 | |||
| d2663bff81 | |||
| b52cf1f741 | |||
| 308e086263 | |||
| 7f7736282f | |||
| 67b0f32456 | |||
| 25b40c03b0 | |||
| 28793bba68 | |||
| 4ae91142f8 | |||
| c92879fbd3 | |||
| a0bb080586 | |||
| fc75bcdfc3 | |||
| 9877f63aed | |||
| 0c98c6b453 | |||
| 831ea6a3bc | |||
| 9c44a0ebfa | |||
| a840ad4594 | |||
| 08ac238d0a | |||
| 7d967a47ae | |||
| 9b7495e005 | |||
| e1ad4219a5 | |||
| cc4503ad28 | |||
| 424336340b | |||
| a8308a5c02 | |||
| 02a46e083d | |||
| 98fd6dd8e1 | |||
| 3a30fea862 | |||
| 7d6d2f5688 | |||
| b5074e4e46 | |||
| a1d941f049 | |||
| 80171713e0 | |||
| c0301dcbf4 | |||
| cbecb9323c | |||
| 4272618b3f | |||
| fe07a148f1 | |||
| 0ec4190e2e | |||
| 0ec3787150 | |||
| 59faf73f04 | |||
| 3e0c4fd73e | |||
| 33af088560 | |||
| 5ea4ef3cf3 | |||
| 33483cf32d | |||
| cd00bc13d4 | |||
| 26ec9666b9 | |||
| f672c1daa6 | |||
| 5f1f0f253d | |||
| 888c4ab209 | |||
| 551d9cc66f | |||
| 9045402c9a | |||
| 0817443e79 | |||
| a4c624802b | |||
| 79cf4100a1 | |||
| fe9486e5fa | |||
| bcf2745ab6 | |||
| c4f16eb76c | |||
| 5925ea8406 | |||
| 6613f1b088 | |||
| 3ea9d5b121 | |||
| 0891be197f | |||
| 2b25d81144 | |||
| 50c09904a9 | |||
| 7aa37b2447 | |||
| d239857d9a | |||
| 3974ba5de5 | |||
| 5cef7fdd18 | |||
| 092f007ed3 | |||
| edf3a43950 | |||
| 45d78c0dce | |||
| d63f67a2af | |||
| ad00eae251 | |||
| 2c35543e73 | |||
| 7c75736719 | |||
| 9c2a0d272a | |||
| 4912e2bccf | |||
| f8c8abd70b | |||
| 7726adeff0 | |||
| e05fd64760 | |||
| 4280444d70 | |||
| 6491184402 | |||
| 171abb3161 | |||
| e179def3cc | |||
| bc6c48bb24 | |||
| 57845a5508 | |||
| 10ff6b118c | |||
| 2288309395 | |||
| a19e7c40a2 | |||
| de56cd0dce | |||
| 8ddec6ed8b | |||
| d9e00dc0db | |||
| 6c30e2c2ed | |||
| 329d224846 | |||
| 734868abc2 | |||
| f554f463b3 | |||
| fa4c83812d | |||
| 8cc5685428 | |||
| 7bf1e5c0f9 | |||
| 7037106bff | |||
| eca36c70a2 | |||
| 7b8d8bd92b | |||
| 397d33a95a | |||
| 08c2e4530e | |||
| 9ec0e2e7c0 | |||
| aacfcd517e | |||
| 27ff51e7a1 | |||
| 821d3296ea | |||
| 46ed1bc276 | |||
| 8711f967a1 | |||
| 5c8aed5793 | |||
| c40f34d3de | |||
| ef2ae00ff2 | |||
| 608bbd50e7 | |||
| e3024e6ffd | |||
| b4978c0869 | |||
| cd1d50474f | |||
| 8097a0cb53 | |||
| e081b56a44 | |||
| c2309af3e0 | |||
| 32fc939c7a | |||
| 4c081601e0 | |||
| 85170b883f | |||
| c018604e5d | |||
| 9c8b5e9fd6 | |||
| 151e1bf50f | |||
| c5a8ffc08c | |||
| d4a15dbe74 | |||
| 0790a1ddad | |||
| f8881d5bd6 | |||
| 6b3ead747b | |||
| dadef81158 | |||
| 644ea22b5f | |||
| f4da354ae7 | |||
| a59f41463f | |||
| 9872ad3aef | |||
| d0880ccb03 | |||
| 237eeea5a6 | |||
| 41bf6ff884 | |||
| 991aa98f98 | |||
| 86fa1046d5 | |||
| 3397807670 | |||
| cdda709886 | |||
| 023bb97e66 | |||
| cf38305f28 | |||
| 0e3674b2b0 | |||
| 3ccb8154eb | |||
| b5ac18121d | |||
| b613f8fbc7 | |||
| cacaffa5c3 | |||
| 691e3aba09 | |||
| 70a0caa11f | |||
| e808e529cd | |||
| 05a5307e22 | |||
| 2a2c23d116 | |||
| a092d98523 | |||
| b5f06af126 | |||
| 6cb43428d1 | |||
| df7dbffbff | |||
| 94421a6b12 | |||
| 7805e89bfe | |||
| 3079eaa2e5 | |||
| 2151147f2d | |||
| 3e69579f5a | |||
| 808ed1d2f1 | |||
| b40e085d28 | |||
| d603567e21 | |||
| c6fdd38e84 | |||
| ae181cf92f | |||
| 1be974afbb | |||
| 4c0baf242b | |||
| b12621b994 | |||
| 4701edc12e | |||
| d29aab7c70 | |||
| 41f80fc898 | |||
| 0f7551ab5b | |||
| 7c58749806 | |||
| 050ed37819 | |||
| 83a4344a01 | |||
| 9b50f3d6b7 | |||
| e945bc9413 | |||
| 3b720ed56e | |||
| ce8587b24d | |||
| 1c8f686c19 | |||
| a0f38053d3 | |||
| cb012186d9 |
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+21
@@ -86,6 +86,14 @@ 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
|
||||
|
||||
# Local backup directory (from testing)
|
||||
backup/
|
||||
|
||||
@@ -94,3 +102,16 @@ backup/
|
||||
|
||||
# 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/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.0.0-beta.0"
|
||||
".": "3.28.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.2"
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
+849
@@ -5,6 +5,855 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.28.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.0-beta.0...v3.28.1-beta.0) (2026-04-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply sort direction in gallery and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([3716ff5](https://github.com/the-luap/picpeak/commit/3716ff50854766bde588fbd6b9027f8647e59150))
|
||||
* apply sort direction in gallery view and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([dffe057](https://github.com/the-luap/picpeak/commit/dffe057772c922ab6a213e25f171157e0c2badf8))
|
||||
|
||||
## [3.28.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.27.0-beta.0...v3.28.0-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([b1dfbe4](https://github.com/the-luap/picpeak/commit/b1dfbe4c2fe271d8087974d02cf724f04058bdc9))
|
||||
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([15a8ab4](https://github.com/the-luap/picpeak/commit/15a8ab41fd1c94e3397d300b161cd1fdd459ea05))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([54badef](https://github.com/the-luap/picpeak/commit/54badefc51b834d55530722f87c81a6ade33e35b))
|
||||
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([77f07e9](https://github.com/the-luap/picpeak/commit/77f07e9329e47f6ac5040f2e85d2710ebbea3ced))
|
||||
|
||||
## [3.27.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.2-beta.0...v3.27.0-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
|
||||
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
|
||||
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
|
||||
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
|
||||
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
|
||||
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
|
||||
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
|
||||
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
|
||||
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
|
||||
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
|
||||
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
|
||||
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
|
||||
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
|
||||
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
|
||||
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
|
||||
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
|
||||
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
|
||||
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
|
||||
* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
|
||||
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
|
||||
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
|
||||
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
|
||||
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
|
||||
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
|
||||
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
|
||||
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
|
||||
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
|
||||
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
|
||||
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
|
||||
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
|
||||
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
|
||||
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
|
||||
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
|
||||
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
|
||||
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
|
||||
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
|
||||
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
|
||||
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
|
||||
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
|
||||
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
|
||||
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
|
||||
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
|
||||
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
|
||||
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
|
||||
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
|
||||
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
|
||||
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
|
||||
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
|
||||
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
|
||||
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
|
||||
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
|
||||
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
|
||||
* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
|
||||
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
|
||||
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
|
||||
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
|
||||
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
|
||||
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
|
||||
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
|
||||
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
|
||||
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
|
||||
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
|
||||
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
|
||||
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
|
||||
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
|
||||
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
|
||||
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
|
||||
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
|
||||
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
|
||||
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
|
||||
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
|
||||
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
|
||||
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
|
||||
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
|
||||
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
|
||||
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
|
||||
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
|
||||
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
|
||||
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
|
||||
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
|
||||
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
|
||||
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
|
||||
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
|
||||
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
|
||||
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
|
||||
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
|
||||
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
|
||||
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
|
||||
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
|
||||
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
|
||||
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
|
||||
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
|
||||
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
|
||||
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
|
||||
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
|
||||
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
|
||||
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
|
||||
* update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
|
||||
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
|
||||
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
|
||||
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
|
||||
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
|
||||
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
|
||||
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
|
||||
|
||||
## [3.26.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.1-beta.0...v3.26.2-beta.0) (2026-04-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
|
||||
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
|
||||
|
||||
## [3.26.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.0-beta.0...v3.26.1-beta.0) (2026-04-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
|
||||
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
|
||||
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
|
||||
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
|
||||
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
|
||||
|
||||
## [3.26.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.25.0-beta.0...v3.26.0-beta.0) (2026-04-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
|
||||
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
|
||||
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
|
||||
|
||||
## [3.25.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.1-beta.0...v3.25.0-beta.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
|
||||
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
|
||||
|
||||
## [3.24.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.0-beta.0...v3.24.1-beta.0) (2026-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
|
||||
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
|
||||
|
||||
## [3.24.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.23.0-beta.0...v3.24.0-beta.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
|
||||
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
|
||||
|
||||
## [3.23.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.22.0-beta.0...v3.23.0-beta.0) (2026-04-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
|
||||
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
|
||||
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
|
||||
|
||||
## [3.22.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.1-beta.0...v3.22.0-beta.0) (2026-03-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
|
||||
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
|
||||
|
||||
## [3.21.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.0-beta.0...v3.21.1-beta.0) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
|
||||
|
||||
## [3.21.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.1-beta.0...v3.21.0-beta.0) (2026-03-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
|
||||
|
||||
## [3.20.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.0-beta.0...v3.20.1-beta.0) (2026-03-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
|
||||
|
||||
## [3.20.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.2-beta.0...v3.20.0-beta.0) (2026-03-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
|
||||
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
|
||||
|
||||
## [3.19.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.1-beta.0...v3.19.2-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
|
||||
|
||||
## [3.19.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.0-beta.0...v3.19.1-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
|
||||
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
|
||||
|
||||
## [3.19.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.2-beta.0...v3.19.0-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
|
||||
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
|
||||
|
||||
## [3.18.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.1-beta.0...v3.18.2-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
|
||||
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
|
||||
|
||||
## [3.18.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.0-beta.0...v3.18.1-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
|
||||
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
|
||||
|
||||
## [3.18.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.2-beta.0...v3.18.0-beta.0) (2026-03-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
|
||||
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
|
||||
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
|
||||
|
||||
## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
|
||||
## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
|
||||
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
|
||||
|
||||
## [3.17.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.16.0-beta.0...v3.17.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
|
||||
## [3.16.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.3-beta.0...v3.16.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
|
||||
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
|
||||
|
||||
## [3.15.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.2-beta.0...v3.15.3-beta.0) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
|
||||
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
|
||||
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
|
||||
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
|
||||
|
||||
## [2.6.0](https://github.com/the-luap/picpeak/compare/v2.5.1...v2.6.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
|
||||
## [2.5.1](https://github.com/the-luap/picpeak/compare/v2.5.0...v2.5.1) (2026-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
|
||||
|
||||
## [2.5.0](https://github.com/the-luap/picpeak/compare/v2.4.0...v2.5.0) (2026-02-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
|
||||
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
|
||||
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
|
||||
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
|
||||
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
|
||||
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
## [3.15.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.1-beta.0...v3.15.2-beta.0) (2026-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
|
||||
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
|
||||
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
|
||||
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
|
||||
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
|
||||
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
|
||||
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
|
||||
## [3.15.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.0-beta.0...v3.15.1-beta.0) (2026-02-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
|
||||
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
|
||||
|
||||
## [3.15.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.14.0-beta.0...v3.15.0-beta.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
|
||||
|
||||
## [3.14.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.1-beta.0...v3.14.0-beta.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
|
||||
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
|
||||
## [3.13.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.0-beta.0...v3.13.1-beta.0) (2026-02-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
|
||||
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
|
||||
|
||||
## [3.13.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.12.0-beta.0...v3.13.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
|
||||
|
||||
## [3.12.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.11.0-beta.0...v3.12.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
|
||||
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
|
||||
|
||||
## [3.11.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.1-beta.0...v3.11.0-beta.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
|
||||
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
|
||||
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
|
||||
|
||||
## [3.10.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.0-beta.0...v3.10.1-beta.0) (2026-02-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
|
||||
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
|
||||
|
||||
## [3.10.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.9.0-beta.0...v3.10.0-beta.0) (2026-02-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
|
||||
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
|
||||
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
|
||||
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
|
||||
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
|
||||
|
||||
## [3.9.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.8.0-beta.0...v3.9.0-beta.0) (2026-02-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
|
||||
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
|
||||
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
|
||||
|
||||
## [3.8.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.7.0-beta.0...v3.8.0-beta.0) (2026-01-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
|
||||
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
|
||||
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
|
||||
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
|
||||
|
||||
## [3.7.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.6.0-beta.0...v3.7.0-beta.0) (2026-01-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
|
||||
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
|
||||
|
||||
## [3.6.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.5.0-beta.0...v3.6.0-beta.0) (2026-01-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
|
||||
## [3.5.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.4.0-beta.0...v3.5.0-beta.0) (2026-01-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
|
||||
## [3.4.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.3.0-beta.0...v3.4.0-beta.0) (2026-01-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
|
||||
## [3.3.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.5-beta.0...v3.3.0-beta.0) (2026-01-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
|
||||
## [3.2.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.4-beta.0...v3.2.5-beta.0) (2026-01-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
|
||||
## [3.2.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.3-beta.0...v3.2.4-beta.0) (2026-01-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
|
||||
## [3.2.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.2-beta.0...v3.2.3-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
|
||||
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
|
||||
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
|
||||
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
|
||||
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
|
||||
|
||||
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,27 @@
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
|
||||
</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.
|
||||
|
||||

|
||||
|
||||
## 🎮 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 +83,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)
|
||||
@@ -113,8 +128,8 @@ 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
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
@@ -327,6 +342,8 @@ 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://github.com/the-luap/picpeak/issues">Support</a>
|
||||
|
||||
+6
-6
@@ -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!
|
||||
+21
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-9
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
@@ -11,10 +11,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
@@ -27,15 +23,15 @@ RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
# 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 and postgresql-client for database checks
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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');
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+886
-954
File diff suppressed because it is too large
Load Diff
+14
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.3.2",
|
||||
"version": "3.28.1-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"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",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
@@ -18,18 +19,19 @@
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.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",
|
||||
@@ -42,7 +44,7 @@
|
||||
"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",
|
||||
@@ -64,7 +66,13 @@
|
||||
"tar-fs": "2.1.4"
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+114
-22
@@ -194,13 +194,23 @@ function composeInlineStyles(payload) {
|
||||
return cssSegments.join('\n\n');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderBrandHeader(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
||||
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
|
||||
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||
|
||||
const tagline = branding.companyTagline
|
||||
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
|
||||
: '';
|
||||
|
||||
return `<header class="site-header">
|
||||
@@ -224,13 +234,14 @@ function renderBrandHeader(branding) {
|
||||
}
|
||||
|
||||
function renderBrandFooter(branding) {
|
||||
const displayName = branding.companyName || 'PicPeak';
|
||||
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
||||
const footerNote = branding.footerText
|
||||
? `<p>${branding.footerText}</p>`
|
||||
? `<p>${escapeHtml(branding.footerText)}</p>`
|
||||
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||
|
||||
const supportLink = branding.supportEmail
|
||||
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||
const supportEmail = escapeHtml(branding.supportEmail || '');
|
||||
const supportLink = supportEmail
|
||||
? `<a href="mailto:${supportEmail}">Support</a>`
|
||||
: '';
|
||||
|
||||
const legalLinks = `
|
||||
@@ -252,10 +263,29 @@ function renderBrandFooter(branding) {
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function buildSeoMetaTags(seoSettings) {
|
||||
const tags = [];
|
||||
const robotsDirectives = [];
|
||||
|
||||
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
|
||||
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
|
||||
|
||||
if (robotsDirectives.length > 0) {
|
||||
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
|
||||
}
|
||||
|
||||
if (seoSettings.seo_meta_noai) {
|
||||
tags.push('<meta name="robots" content="noai, noimageai" />');
|
||||
}
|
||||
|
||||
return tags.join('\n ');
|
||||
}
|
||||
|
||||
function buildPublicSiteDocument(payload) {
|
||||
const inlineStyles = composeInlineStyles(payload);
|
||||
const header = renderBrandHeader(payload.branding);
|
||||
const footer = renderBrandFooter(payload.branding);
|
||||
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -263,8 +293,9 @@ function buildPublicSiteDocument(payload) {
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${payload.title}</title>
|
||||
<title>${escapeHtml(payload.title)}</title>
|
||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||
${seoMeta}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
@@ -296,6 +327,21 @@ async function handlePublicSiteRequest(req, res, next) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject SEO meta settings into payload
|
||||
try {
|
||||
const seoRows = await db('app_settings')
|
||||
.where('setting_type', 'seo')
|
||||
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
|
||||
.select('setting_key', 'setting_value');
|
||||
const seoSettings = {};
|
||||
for (const row of seoRows) {
|
||||
let val = row.setting_value;
|
||||
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
|
||||
seoSettings[row.setting_key] = val;
|
||||
}
|
||||
payload.seoSettings = seoSettings;
|
||||
} catch {}
|
||||
|
||||
const document = buildPublicSiteDocument(payload);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
@@ -324,8 +370,22 @@ async function initializeRateLimiters() {
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// CSRF protection: require JSON Content-Type on mutating API requests
|
||||
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
|
||||
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
||||
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
@@ -351,8 +411,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
|
||||
|
||||
// Middleware to set CORS headers for static files
|
||||
const setCorsHeaders = (req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
const origin = req.headers.origin;
|
||||
const staticAllowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:3005',
|
||||
process.env.ADMIN_URL || 'http://localhost:3005'
|
||||
];
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
staticAllowedOrigins.push(
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3002',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3000'
|
||||
);
|
||||
}
|
||||
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
next();
|
||||
};
|
||||
@@ -395,24 +470,37 @@ if (process.env.NODE_ENV === 'development') {
|
||||
});
|
||||
}
|
||||
|
||||
// robots.txt endpoint (dynamic, served from DB settings)
|
||||
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||
app.get('/robots.txt', async (req, res) => {
|
||||
try {
|
||||
const robotsTxt = await generateRobotsTxt();
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.status(200).send(robotsTxt);
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate robots.txt', { error: error.message });
|
||||
// Safe default for a private photo platform
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.status(200).send('User-agent: *\nDisallow: /\n');
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
// Check database connectivity
|
||||
await db.raw('SELECT 1');
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
timestamp: new Date().toISOString()
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
res.status(503).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -424,19 +512,23 @@ app.use('/api/auth', authRoutes);
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
app.use('/api/gallery', require('./src/routes/galleryGuests'));
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/admin/auth', adminAuthRoutes);
|
||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin', require('./src/routes/adminGuests'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
|
||||
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
// Check if gallery has expired (only if expires_at is set)
|
||||
// Galleries with null expires_at never expire
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
|
||||
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for the guest
|
||||
* Generate a unique identifier for the guest.
|
||||
*
|
||||
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
|
||||
* unique per person per event (set by the resolveGuest middleware). When
|
||||
* present it takes precedence, so rate limits and deduplication become
|
||||
* per-person instead of per-device.
|
||||
*
|
||||
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
|
||||
* matching prior behavior.
|
||||
*/
|
||||
function generateGuestIdentifier(req) {
|
||||
if (req.guest && req.guest.identifier) {
|
||||
return req.guest.identifier;
|
||||
}
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
return crypto
|
||||
|
||||
@@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Check if the request carries a valid admin preview token (Feature 3)
|
||||
function isAdminPreview(req) {
|
||||
const previewToken = req.query?.preview;
|
||||
if (!previewToken) return false;
|
||||
try {
|
||||
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
return decoded.type === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
@@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const adminPreview = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
const q = db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreview) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
@@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
const adminPreviewToken = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
const q = db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreviewToken) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
|
||||
// Verify the token's eventId matches
|
||||
@@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
} else {
|
||||
// Fallback to using eventId from token
|
||||
const adminPreviewFallback = isAdminPreview(req);
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
const q = db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
if (!adminPreviewFallback) {
|
||||
q.where({ is_draft: formatBoolean(false) });
|
||||
}
|
||||
return await q.select('*').first();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,8 +123,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.accessLevel = decoded.accessLevel || 'guest';
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
|
||||
// Create client info for logging (similar to secureImageMiddleware but simpler)
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
@@ -121,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyGalleryAccess
|
||||
verifyGalleryAccess,
|
||||
isAdminPreview
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
/**
|
||||
* Non-blocking middleware. Reads an optional guest token from the request and,
|
||||
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
|
||||
*
|
||||
* If the token is missing, malformed, or expired → req.guest = null and the
|
||||
* request continues. Downstream handlers (e.g. feedback submission) enforce
|
||||
* presence explicitly based on event feedback settings (identity_mode).
|
||||
*/
|
||||
async function resolveGuest(req, res, next) {
|
||||
try {
|
||||
const slug = req.params?.slug;
|
||||
const token = getGuestTokenFromRequest(req, slug);
|
||||
if (!token) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
decoded = verified.payload;
|
||||
} catch (err) {
|
||||
// Invalid or expired guest tokens are silently ignored so that public
|
||||
// gallery browsing continues to work even if the token is stale.
|
||||
logger.debug('Invalid guest token', { reason: err.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (decoded.type !== 'guest') {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Verify the guest row still exists and is not soft-deleted.
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (!guest) {
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
req.guest = {
|
||||
id: guest.id,
|
||||
eventId: guest.event_id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
email: guest.email || null,
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error('resolveGuest middleware error', { error: error.message });
|
||||
req.guest = null;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking middleware that 401s if no guest identity was resolved.
|
||||
* Use this on endpoints that require a valid guest session.
|
||||
*/
|
||||
function requireGuest(req, res, next) {
|
||||
if (!req.guest) {
|
||||
return res.status(401).json({ error: 'Guest identity required' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a new guest JWT. Scoped to a specific event and guest row.
|
||||
* Expiry matches the gallery token default (24h).
|
||||
*/
|
||||
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
|
||||
return jwt.sign(
|
||||
{
|
||||
type: 'guest',
|
||||
guestId,
|
||||
eventId,
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{
|
||||
issuer: 'picpeak-auth',
|
||||
expiresIn,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveGuest,
|
||||
requireGuest,
|
||||
signGuestToken,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
/**
|
||||
* Middleware to enforce event ownership for non-super_admin users.
|
||||
* Super admins bypass the check. Other admins can only access events they created.
|
||||
*/
|
||||
function requireEventOwnership(req, res, next) {
|
||||
if (req.admin.roleName === 'super_admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const eventId = req.params.eventId || req.params.id;
|
||||
if (!eventId) {
|
||||
return res.status(400).json({ error: 'Event ID is required' });
|
||||
}
|
||||
|
||||
db('events')
|
||||
.where('id', eventId)
|
||||
.first()
|
||||
.then((event) => {
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
// Allow access if: event has no owner (legacy/system), or admin owns it
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(500).json({ error: 'Failed to verify ownership' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership };
|
||||
@@ -85,18 +85,28 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
const now = Date.now();
|
||||
const lastActivity = sessions.get(token);
|
||||
const timeout = await getSessionTimeout();
|
||||
|
||||
// If session exists, check if it's expired
|
||||
|
||||
if (lastActivity) {
|
||||
// Existing session — check if idle too long
|
||||
if (now - lastActivity > timeout) {
|
||||
sessions.delete(token);
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// First request with this token — check if token was issued longer ago than the timeout
|
||||
// This prevents old/stolen tokens from bypassing session timeout after server restart
|
||||
const tokenIssuedAt = (decoded.iat || 0) * 1000; // iat is in seconds
|
||||
if (now - tokenIssuedAt > timeout) {
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update last activity
|
||||
sessions.set(token, now);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
@@ -82,7 +83,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
});
|
||||
|
||||
// Get single archive details
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -138,7 +139,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, re
|
||||
});
|
||||
|
||||
// Restore archive
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
|
||||
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -301,7 +302,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), as
|
||||
});
|
||||
|
||||
// Download archive
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
|
||||
router.get('/:id/download', adminAuth, requirePermission('archives.download'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
@@ -350,7 +351,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), a
|
||||
});
|
||||
|
||||
// Delete archive permanently
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const archive = await db('events')
|
||||
.where('id', req.params.id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -7,6 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -122,15 +124,36 @@ router.post('/change-password', [
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
// Update password, set password_changed_at to invalidate existing tokens, and clear must_change_password flag
|
||||
const now = new Date();
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
password_changed_at: now,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
updated_at: now
|
||||
});
|
||||
|
||||
// Issue a new token so the session remains valid after password_changed_at invalidated the old one.
|
||||
// Set iat to 1 second after password_changed_at to guarantee the token passes the
|
||||
// "iat < password_changed_at" check in auth middleware (password_changed_at has ms precision
|
||||
// but JWT iat is floored to seconds, which can cause the new token to be rejected).
|
||||
const iatAfterPasswordChange = Math.floor(now.getTime() / 1000) + 1;
|
||||
const newToken = jwt.sign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
type: 'admin',
|
||||
role: user.role_name,
|
||||
iat: iatAfterPasswordChange,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, newToken);
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_changed',
|
||||
{ admin_id: userId },
|
||||
|
||||
@@ -258,6 +258,13 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
||||
break;
|
||||
}
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
@@ -355,12 +362,17 @@ router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view')
|
||||
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath } = req.body;
|
||||
|
||||
|
||||
if (!manifestPath) {
|
||||
return res.status(400).json({ error: 'manifestPath is required' });
|
||||
}
|
||||
|
||||
const result = await validateBackupManifest(manifestPath);
|
||||
|
||||
// Prevent path traversal — manifest must be within backup directory
|
||||
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
const safePath = safePathJoin(backupBasePath, manifestPath);
|
||||
|
||||
const result = await validateBackupManifest(safePath);
|
||||
|
||||
res.json({
|
||||
valid: result.valid,
|
||||
@@ -456,19 +468,24 @@ router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup
|
||||
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const { manifestPath, manifestData } = req.body;
|
||||
|
||||
|
||||
if (!manifestPath && !manifestData) {
|
||||
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
|
||||
}
|
||||
|
||||
|
||||
if (manifestData) {
|
||||
// Validate provided manifest data directly
|
||||
const validationResult = await validateManifestData(manifestData);
|
||||
return res.json(validationResult);
|
||||
}
|
||||
|
||||
|
||||
// Prevent path traversal — manifest must be within backup directory
|
||||
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
const safePath = safePathJoin(backupBasePath, manifestPath);
|
||||
|
||||
// Use existing validation function for path
|
||||
const result = await validateBackupManifest(manifestPath);
|
||||
const result = await validateBackupManifest(safePath);
|
||||
|
||||
res.json({
|
||||
valid: result.valid,
|
||||
@@ -757,10 +774,14 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
|
||||
try {
|
||||
const { path: targetPath = '', recursive = true } = req.query;
|
||||
const checksums = {};
|
||||
|
||||
|
||||
// Get storage path
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
|
||||
let basePath = storagePath;
|
||||
if (targetPath) {
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
basePath = safePathJoin(storagePath, targetPath);
|
||||
}
|
||||
|
||||
// Calculate checksums for files
|
||||
async function calculateDirChecksums(dirPath, relative = '') {
|
||||
|
||||
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
// Update a category
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().withMessage('Category name is required')
|
||||
body('name').notEmpty().withMessage('Category name is required'),
|
||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||
if (value === null || value === undefined) return true;
|
||||
return Number.isInteger(Number(value));
|
||||
}).withMessage('hero_photo_id must be an integer or null')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
|
||||
const { id } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
const { name, hero_photo_id } = req.body;
|
||||
|
||||
const category = await db('photo_categories').where('id', id).first();
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
|
||||
const updateData = {
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim()
|
||||
};
|
||||
|
||||
// Update hero_photo_id if provided (including null to clear it)
|
||||
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
|
||||
updateData.hero_photo_id = hero_photo_id || null;
|
||||
}
|
||||
|
||||
await db('photo_categories')
|
||||
.where('id', id)
|
||||
.update({
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim()
|
||||
});
|
||||
|
||||
.update(updateData);
|
||||
|
||||
const updated = await db('photo_categories').where('id', id).first();
|
||||
|
||||
|
||||
// Log activity
|
||||
await logActivity('category_updated',
|
||||
{ categoryName: name },
|
||||
{ categoryName: name, heroPhotoId: hero_photo_id },
|
||||
category.event_id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Set category hero photo (#163)
|
||||
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
|
||||
if (value === null || value === undefined) return true;
|
||||
return Number.isInteger(Number(value));
|
||||
}).withMessage('hero_photo_id must be an integer or null')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { hero_photo_id } = req.body;
|
||||
|
||||
const category = await db('photo_categories').where('id', id).first();
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
// If hero_photo_id is provided, verify it belongs to a photo in this category
|
||||
if (hero_photo_id) {
|
||||
const photo = await db('photos').where('id', hero_photo_id).first();
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
}
|
||||
|
||||
await db('photo_categories')
|
||||
.where('id', id)
|
||||
.update({ hero_photo_id: hero_photo_id || null });
|
||||
|
||||
const updated = await db('photo_categories').where('id', id).first();
|
||||
|
||||
// Log activity
|
||||
await logActivity('category_hero_updated',
|
||||
{ categoryName: category.name, heroPhotoId: hero_photo_id },
|
||||
category.event_id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category hero:', error);
|
||||
res.status(500).json({ error: 'Failed to update category hero' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a category
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
|
||||
+198
-148
@@ -4,6 +4,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { wrapEmailHtml } = require('../services/emailProcessor');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -60,6 +61,12 @@ router.post('/config', [
|
||||
tls_reject_unauthorized
|
||||
} = req.body;
|
||||
|
||||
// Validate SMTP host is not a private/internal address (SSRF protection)
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(smtp_host)) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
|
||||
// Check if config exists
|
||||
const existingConfig = await db('email_configs').first();
|
||||
|
||||
@@ -159,22 +166,26 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
|
||||
const transporter = nodemailer.createTransport(transportConfig);
|
||||
|
||||
// Send test email
|
||||
// Send test email with the same wrapper used for all other emails
|
||||
const subject = 'Test Email - Photo Sharing Platform';
|
||||
const testHtmlBody = `
|
||||
<h2>Test Email Successful!</h2>
|
||||
<p>This is a test email from your Photo Sharing platform.</p>
|
||||
<p>If you're seeing this, your email configuration is working correctly.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Sent from: ${config.from_email}<br>
|
||||
SMTP Host: ${config.smtp_host}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`;
|
||||
const wrappedHtml = await wrapEmailHtml(testHtmlBody, subject);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
to: test_email,
|
||||
subject: 'Test Email - Photo Sharing Platform',
|
||||
html: `
|
||||
<h2>Test Email Successful!</h2>
|
||||
<p>This is a test email from your Photo Sharing platform.</p>
|
||||
<p>If you're seeing this, your email configuration is working correctly.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Sent from: ${config.from_email}<br>
|
||||
SMTP Host: ${config.smtp_host}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`,
|
||||
subject,
|
||||
html: wrappedHtml,
|
||||
text: 'Test Email Successful! Your email configuration is working correctly.'
|
||||
});
|
||||
|
||||
@@ -237,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: get translations for a template, with legacy column fallback
|
||||
async function getTemplateTranslations(templateId, template) {
|
||||
const translations = {};
|
||||
try {
|
||||
const rows = await db('email_template_translations')
|
||||
.where('template_id', templateId)
|
||||
.select('language', 'subject', 'body_html', 'body_text');
|
||||
|
||||
rows.forEach(row => {
|
||||
translations[row.language] = {
|
||||
subject: row.subject || '',
|
||||
body_html: row.body_html || '',
|
||||
body_text: row.body_text || '',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Translations table might not exist yet (pre-migration)
|
||||
// Fall back to legacy columns
|
||||
if (template.subject_en !== undefined) {
|
||||
translations.en = {
|
||||
subject: template.subject_en || '',
|
||||
body_html: template.body_html_en || '',
|
||||
body_text: template.body_text_en || '',
|
||||
};
|
||||
translations.de = {
|
||||
subject: template.subject_de || '',
|
||||
body_html: template.body_html_de || '',
|
||||
body_text: template.body_text_de || '',
|
||||
};
|
||||
} else {
|
||||
translations.en = {
|
||||
subject: template.subject || '',
|
||||
body_html: template.body_html || '',
|
||||
body_text: template.body_text || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -244,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
.select('*')
|
||||
.orderBy('template_key');
|
||||
|
||||
// Parse variables JSON and format for multi-language support
|
||||
const formattedTemplates = templates.map(template => {
|
||||
const result = {
|
||||
const formattedTemplates = [];
|
||||
for (const template of templates) {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
formattedTemplates.push({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Handle both old and new schema formats
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
result.subject_en = template.subject_en;
|
||||
result.body_html_en = template.body_html_en;
|
||||
result.body_text_en = template.body_text_en;
|
||||
result.subject_de = template.subject_de;
|
||||
result.body_html_de = template.body_html_de;
|
||||
result.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
result.subject_en = template.subject;
|
||||
result.body_html_en = template.body_html;
|
||||
result.body_text_en = template.body_text;
|
||||
result.subject_de = template.subject;
|
||||
result.body_html_de = template.body_html;
|
||||
result.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
@@ -302,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Handle both old and new schema formats
|
||||
const response = {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Check which columns exist and use them appropriately
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
response.subject_en = template.subject_en;
|
||||
response.body_html_en = template.body_html_en;
|
||||
response.body_text_en = template.body_text_en;
|
||||
response.subject_de = template.subject_de;
|
||||
response.body_html_de = template.body_html_de;
|
||||
response.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
response.subject_en = template.subject;
|
||||
response.body_html_en = template.body_html;
|
||||
response.body_text_en = template.body_text;
|
||||
response.subject_de = template.subject;
|
||||
response.body_html_de = template.body_html;
|
||||
response.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update email template
|
||||
// Update email template translations
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
subject_en, subject_de,
|
||||
body_html_en, body_html_de,
|
||||
body_text_en, body_text_de
|
||||
} = req.body;
|
||||
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
// Check which columns exist in the database
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.first();
|
||||
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Determine schema type and update accordingly
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
if (subject_en !== undefined) updateData.subject_en = subject_en;
|
||||
if (subject_de !== undefined) updateData.subject_de = subject_de;
|
||||
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
|
||||
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
|
||||
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
|
||||
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
|
||||
|
||||
// Also update basic columns if they exist
|
||||
if (template.subject !== undefined) {
|
||||
updateData.subject = subject_en || updateData.subject_en;
|
||||
updateData.body_html = body_html_en || updateData.body_html_en;
|
||||
updateData.body_text = body_text_en || updateData.body_text_en || '';
|
||||
}
|
||||
} else {
|
||||
// Old schema - only update basic columns
|
||||
if (subject_en !== undefined) {
|
||||
updateData.subject = subject_en;
|
||||
updateData.body_html = body_html_en;
|
||||
updateData.body_text = body_text_en || '';
|
||||
const { translations } = req.body;
|
||||
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
// Upsert each language translation
|
||||
for (const [language, data] of Object.entries(translations)) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
const existing = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
const row = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.update(row);
|
||||
} else {
|
||||
await db('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language,
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.update(updateData);
|
||||
// Update timestamp on parent template
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update({ updated_at: new Date() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
// Also sync legacy columns for backward compatibility
|
||||
const enData = translations.en;
|
||||
const deData = translations.de;
|
||||
const legacyUpdate = { updated_at: new Date() };
|
||||
const columnInfo = await db('email_templates').columnInfo();
|
||||
|
||||
if (enData && columnInfo.subject_en) {
|
||||
legacyUpdate.subject_en = enData.subject || '';
|
||||
legacyUpdate.body_html_en = enData.body_html || '';
|
||||
legacyUpdate.body_text_en = enData.body_text || '';
|
||||
}
|
||||
if (deData && columnInfo.subject_de) {
|
||||
legacyUpdate.subject_de = deData.subject || '';
|
||||
legacyUpdate.body_html_de = deData.body_html || '';
|
||||
legacyUpdate.body_text_de = deData.body_text || '';
|
||||
}
|
||||
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update(legacyUpdate);
|
||||
|
||||
// Log activity
|
||||
await logActivity('email_template_updated',
|
||||
{ template_key: req.params.key },
|
||||
{ template_key: req.params.key, languages: Object.keys(translations) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -438,29 +453,64 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
}
|
||||
|
||||
const { preview_data, language = 'en' } = req.body;
|
||||
|
||||
// Get the appropriate language version
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Handle backward compatibility
|
||||
let htmlContent = template[htmlField] || template.body_html || '';
|
||||
let textContent = template[textField] || template.body_text || '';
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
|
||||
// Get translation from translations table with fallback
|
||||
let translation = null;
|
||||
try {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to legacy columns
|
||||
}
|
||||
|
||||
let subject = '';
|
||||
let htmlContent = '';
|
||||
let textContent = '';
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlContent = translation.body_html || '';
|
||||
textContent = translation.body_text || '';
|
||||
} else {
|
||||
// Legacy column fallback
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject || '';
|
||||
htmlContent = template[htmlField] || template.body_html || '';
|
||||
textContent = template[textField] || template.body_text || '';
|
||||
}
|
||||
|
||||
if (preview_data) {
|
||||
const escapeHtml = (str) => String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
Object.keys(preview_data).forEach(key => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
htmlContent = htmlContent.replace(regex, preview_data[key]);
|
||||
const escapedValue = escapeHtml(preview_data[key]);
|
||||
htmlContent = htmlContent.replace(regex, escapedValue);
|
||||
textContent = textContent.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, escapeHtml(preview_data[key]));
|
||||
});
|
||||
}
|
||||
|
||||
// Wrap in the full styled email template with header/footer/logo
|
||||
const wrappedHtml = await wrapEmailHtml(htmlContent, subject, language);
|
||||
|
||||
res.json({
|
||||
subject,
|
||||
body_html: htmlContent,
|
||||
body_html: wrappedHtml,
|
||||
body_text: textContent,
|
||||
language
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Admin Event Types Routes
|
||||
* CRUD operations for managing customizable event types
|
||||
*
|
||||
* @module routes/adminEventTypes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /admin/event-types
|
||||
* Get all event types (for admin management)
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const eventTypes = await eventTypeService.getAllEventTypes({
|
||||
activeOnly: !includeInactive
|
||||
});
|
||||
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/active
|
||||
* Get only active event types (for dropdowns/selection)
|
||||
*/
|
||||
router.get('/active', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await eventTypeService.getActiveEventTypes();
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching active event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/:id
|
||||
* Get a single event type by ID
|
||||
*/
|
||||
router.get('/:id', adminAuth, requirePermission('settings.view'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
|
||||
|
||||
if (!eventType) {
|
||||
return res.status(404).json({ error: 'Event type not found' });
|
||||
}
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event type:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types
|
||||
* Create a new event type
|
||||
*/
|
||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().trim().withMessage('Name is required'),
|
||||
body('slug_prefix')
|
||||
.notEmpty()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = req.body;
|
||||
|
||||
const eventType = await eventTypeService.createEventType({
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_created',
|
||||
{ name, slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.status(201).json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error creating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to create event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/event-types/:id
|
||||
* Update an event type
|
||||
*/
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID'),
|
||||
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
|
||||
body('slug_prefix')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 }),
|
||||
body('is_active').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_updated',
|
||||
{ id, changes: Object.keys(updates) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error updating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to update event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /admin/event-types/:id
|
||||
* Delete an event type (only non-system types with no events)
|
||||
*/
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await eventTypeService.deleteEventType(parseInt(id));
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_deleted',
|
||||
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event type deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to delete event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types/reorder
|
||||
* Reorder event types by providing an array of IDs in the desired order
|
||||
*/
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { orderedIds } = req.body;
|
||||
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_types_reordered',
|
||||
{ newOrder: orderedIds },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ eventTypes, message: 'Event types reordered successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error reordering event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to reorder event types' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
@@ -40,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null
|
||||
upload_category_id = null,
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
@@ -103,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
@@ -16,7 +17,50 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for event logo uploads
|
||||
const eventLogoStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
@@ -25,14 +69,18 @@ const getEventFieldRequirements = async () => {
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
@@ -47,6 +95,8 @@ const getEventFieldRequirements = async () => {
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
@@ -55,7 +105,53 @@ const getEventFieldRequirements = async () => {
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance)
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size',
|
||||
'branding_logo_position'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_position' && value) {
|
||||
defaults.hero_logo_position = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -74,6 +170,8 @@ const mapEventForApi = (event) => {
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
password_hash: _ph,
|
||||
client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
@@ -104,12 +202,18 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
@@ -143,7 +247,24 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString(),
|
||||
body('default_photo_sort').optional().isIn([
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -182,7 +303,25 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
css_template_id = null
|
||||
css_template_id = null,
|
||||
// Hero logo settings
|
||||
hero_logo_visible = true,
|
||||
hero_logo_size = 'medium',
|
||||
hero_logo_position = 'top',
|
||||
// Header style settings
|
||||
header_style = 'standard',
|
||||
hero_divider_style = 'wave',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor = 'center',
|
||||
// Photo cap
|
||||
photo_cap = null,
|
||||
// Client access settings (#172)
|
||||
client_access_enabled = false,
|
||||
client_password = null,
|
||||
// Draft mode
|
||||
is_draft = true,
|
||||
// Default photo sort
|
||||
default_photo_sort = 'upload_date_desc'
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -201,6 +340,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
if (fieldRequirements.require_admin_email && !admin_email) {
|
||||
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_event_date && !event_date) {
|
||||
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ errors: validationErrors });
|
||||
@@ -245,10 +387,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
|
||||
// Use event_date in slug if provided, otherwise use random suffix
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
@@ -256,7 +401,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -264,15 +409,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
// If expiration is not required, expires_at will be null (never expires)
|
||||
// If event_date is not provided, use current date as base for expiration
|
||||
let expires_at = null;
|
||||
if (fieldRequirements.require_expiration) {
|
||||
const baseDate = event_date || new Date().toISOString().split('T')[0];
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(baseDate);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -280,22 +430,48 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158).
|
||||
let effectiveHeaderStyle = header_style;
|
||||
let effectiveDividerStyle = hero_divider_style;
|
||||
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
|
||||
try {
|
||||
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
|
||||
const parsed = JSON.parse(color_theme);
|
||||
if (!req.body.header_style && parsed.headerStyle) {
|
||||
effectiveHeaderStyle = parsed.headerStyle;
|
||||
}
|
||||
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
|
||||
effectiveDividerStyle = parsed.heroDividerStyle;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// color_theme is not JSON – nothing to extract
|
||||
}
|
||||
}
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
|
||||
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
event_date: event_date || null,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
host_name: customerName || null,
|
||||
host_email: customerEmail || null,
|
||||
admin_email: admin_email || null,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
@@ -305,7 +481,22 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null
|
||||
css_template_id: css_template_id || null,
|
||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
hero_divider_style: effectiveDividerStyle || 'wave',
|
||||
hero_image_anchor: hero_image_anchor || 'center',
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
|
||||
default_photo_sort: default_photo_sort || 'upload_date_desc',
|
||||
// Client access (#172)
|
||||
client_access_enabled: formatBoolean(client_access_enabled),
|
||||
...(client_access_enabled && client_password ? {
|
||||
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
|
||||
client_share_token: crypto.randomBytes(32).toString('hex')
|
||||
} : {})
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -335,14 +526,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue creation email
|
||||
// Queue creation email (only if there is a recipient and event is not a draft)
|
||||
// Language detection is handled by email processor
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
const isDraft = parseBooleanInput(is_draft, true);
|
||||
|
||||
if (customerEmail && !isDraft) {
|
||||
// Build email data with optional client access info
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
@@ -350,13 +540,28 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
};
|
||||
|
||||
// Include client access info in email when enabled (#172)
|
||||
if (client_access_enabled && client_password) {
|
||||
const createdEvent = await db('events').where('id', eventId).first();
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
|
||||
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
|
||||
emailData.client_password = client_password;
|
||||
}
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
@@ -366,8 +571,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
photo_cap: photo_cap || null,
|
||||
is_draft: isDraft,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -384,8 +591,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
const status = req.query.status || 'all';
|
||||
const sortBy = req.query.sortBy || 'created_at';
|
||||
const sortOrder = req.query.sortOrder || 'desc';
|
||||
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
|
||||
const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
|
||||
const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc';
|
||||
|
||||
// Build query
|
||||
let query = db('events');
|
||||
@@ -412,6 +620,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
} else if (status === 'inactive') {
|
||||
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
|
||||
} else if (status === 'draft') {
|
||||
query = query.where('is_draft', formatBoolean(true));
|
||||
} else if (status === 'expiring') {
|
||||
const sevenDaysFromNow = new Date();
|
||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||
@@ -536,12 +746,71 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// Publish a draft event (set is_draft=false and queue creation email)
|
||||
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const event = await db('events').where('id', id).first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!parseBooleanInput(event.is_draft, false)) {
|
||||
return res.status(400).json({ error: 'Event is already published' });
|
||||
}
|
||||
|
||||
// Set is_draft to false
|
||||
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
|
||||
|
||||
// Queue creation email
|
||||
const customerEmail = event.customer_email || event.host_email;
|
||||
const customerName = event.customer_name || event.host_name;
|
||||
if (customerEmail) {
|
||||
const frontendBase = await getFrontendBaseUrl();
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
const emailData = {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date,
|
||||
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
|
||||
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
|
||||
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
welcome_message: event.welcome_message || ''
|
||||
};
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: id,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify(emailData),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity('event_published',
|
||||
{ event_name: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event published successfully', is_draft: false });
|
||||
} catch (error) {
|
||||
logger.error('Error publishing event:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to publish event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('event_name').optional().trim().notEmpty(),
|
||||
body('admin_email').optional().isEmail(),
|
||||
body('is_active').optional().isBoolean(),
|
||||
body('expires_at').optional().isISO8601(),
|
||||
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
@@ -573,7 +842,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
body('overlay_protection').optional().isBoolean(),
|
||||
body('image_quality').optional().isInt({ min: 1, max: 100 }),
|
||||
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
body('password').optional().isString().custom((value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
@@ -582,7 +851,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
|
||||
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
|
||||
// Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point
|
||||
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
|
||||
// Client access settings (#172)
|
||||
body('client_access_enabled').optional().isBoolean(),
|
||||
body('client_password').optional().isString(),
|
||||
body('regenerate_client_token').optional().isBoolean(),
|
||||
body('default_photo_sort').optional().isIn([
|
||||
'upload_date_desc', 'upload_date_asc',
|
||||
'capture_date_desc', 'capture_date_asc',
|
||||
'filename_asc', 'filename_desc'
|
||||
])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -661,6 +948,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||
}
|
||||
|
||||
// Handle client access fields (#172)
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
|
||||
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
|
||||
// Auto-generate client share token when first enabling
|
||||
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
|
||||
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
|
||||
delete updates.client_password;
|
||||
} else {
|
||||
delete updates.client_password;
|
||||
}
|
||||
if (updates.regenerate_client_token) {
|
||||
updates.client_share_token = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
delete updates.regenerate_client_token;
|
||||
|
||||
// Log the update request for debugging
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
@@ -694,6 +1000,43 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Enforce expires_at requirement based on app settings
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
|
||||
if (!updates.expires_at) {
|
||||
const fieldReqs = await getEventFieldRequirements();
|
||||
if (fieldReqs.require_expiration) {
|
||||
return res.status(400).json({ error: 'Expiration date is required.' });
|
||||
}
|
||||
updates.expires_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Sync header_style / hero_divider_style from color_theme JSON when not
|
||||
// explicitly provided in the request body (#158). This ensures the
|
||||
// database columns stay in sync even if the frontend only sends the
|
||||
// serialised theme object.
|
||||
if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) {
|
||||
try {
|
||||
const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : '';
|
||||
if (themeStr.startsWith('{')) {
|
||||
const parsed = JSON.parse(themeStr);
|
||||
if (parsed.headerStyle) {
|
||||
updates.header_style = parsed.headerStyle;
|
||||
}
|
||||
if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) {
|
||||
updates.hero_divider_style = parsed.heroDividerStyle;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// color_theme is not JSON (e.g. preset name) – nothing to extract
|
||||
}
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -714,7 +1057,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
});
|
||||
|
||||
// Delete event
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
|
||||
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -759,7 +1102,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
@@ -768,6 +1111,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete custom event logo if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(event.hero_logo_path);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
@@ -795,7 +1148,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
});
|
||||
|
||||
// Toggle event status
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -835,7 +1188,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), a
|
||||
});
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
@@ -902,7 +1255,7 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -927,7 +1280,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
@@ -985,7 +1339,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
@@ -1094,4 +1448,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file provided' });
|
||||
}
|
||||
|
||||
// Delete old logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||
const logoPath = req.file.path;
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: logoUrl,
|
||||
hero_logo_path: logoPath
|
||||
});
|
||||
|
||||
await logActivity('event_logo_uploaded',
|
||||
{ eventName: event.event_name, filename: req.file.filename },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to upload event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: null,
|
||||
hero_logo_path: null
|
||||
});
|
||||
|
||||
await logActivity('event_logo_removed',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to delete event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
const stats = await fs.stat(f.full);
|
||||
|
||||
// Extract dimensions via Sharp
|
||||
let width = null;
|
||||
let height = null;
|
||||
try {
|
||||
const metadata = await sharp(f.full).metadata();
|
||||
width = metadata.width || null;
|
||||
height = metadata.height || null;
|
||||
} catch (dimErr) {
|
||||
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
|
||||
}
|
||||
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
thumbnail_path: null,
|
||||
type,
|
||||
size_bytes: stats.size,
|
||||
width,
|
||||
height,
|
||||
source_origin: 'external',
|
||||
external_relpath: f.rel
|
||||
})
|
||||
|
||||
@@ -12,11 +12,13 @@ const {
|
||||
validateWordFilter,
|
||||
checkValidation
|
||||
} = require('../utils/feedbackValidation');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
|
||||
// Get event feedback settings
|
||||
router.get('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -42,6 +44,7 @@ router.get('/events/:eventId/feedback-settings',
|
||||
router.put('/events/:eventId/feedback-settings',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
validateFeedbackSettings,
|
||||
checkValidation,
|
||||
@@ -79,6 +82,7 @@ router.put('/events/:eventId/feedback-settings',
|
||||
router.get('/events/:eventId/feedback',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -204,6 +208,7 @@ router.delete('/feedback/:feedbackId',
|
||||
router.get('/events/:eventId/feedback-analytics',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -304,6 +309,7 @@ router.get('/events/:eventId/feedback-analytics',
|
||||
router.get('/events/:eventId/feedback/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
validateEventId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const router = express.Router();
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || '';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async function loadGuestOr404(eventId, guestId, res) {
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ id: guestId, event_id: eventId, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
res.status(404).json({ error: 'Guest not found' });
|
||||
return null;
|
||||
}
|
||||
return guest;
|
||||
}
|
||||
|
||||
function serializeGuest(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
created_at: row.created_at,
|
||||
last_seen_at: row.last_seen_at,
|
||||
email_verified_at: row.email_verified_at,
|
||||
is_deleted: row.is_deleted,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const str = value == null ? '' : String(value);
|
||||
if (/[,"\n\r]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests — list guests with aggregated counts
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const rows = await db('gallery_guests')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
|
||||
})
|
||||
.where('gallery_guests.event_id', eventId)
|
||||
.where('gallery_guests.is_deleted', false)
|
||||
.groupBy('gallery_guests.id')
|
||||
.select(
|
||||
'gallery_guests.id',
|
||||
'gallery_guests.name',
|
||||
'gallery_guests.email',
|
||||
'gallery_guests.created_at',
|
||||
'gallery_guests.last_seen_at',
|
||||
'gallery_guests.email_verified_at',
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
|
||||
const guests = rows.map((r) => ({
|
||||
...serializeGuest(r),
|
||||
stats: {
|
||||
likes: parseInt(r.likes, 10) || 0,
|
||||
favorites: parseInt(r.favorites, 10) || 0,
|
||||
comments: parseInt(r.comments, 10) || 0,
|
||||
ratings: parseInt(r.ratings, 10) || 0,
|
||||
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ guests });
|
||||
} catch (error) {
|
||||
logger.error('Error listing guests:', error);
|
||||
res.status(500).json({ error: 'Failed to list guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
|
||||
// guest pick count (Phase 2 aggregate view)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/aggregate',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.photo_id', '=', 'photos.id')
|
||||
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
|
||||
.andOnNotNull('photo_feedback.guest_id');
|
||||
})
|
||||
.where('photos.event_id', eventId)
|
||||
.groupBy('photos.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
|
||||
)
|
||||
.orderBy('picker_count', 'desc')
|
||||
.orderBy('photos.id', 'desc');
|
||||
|
||||
res.json({
|
||||
photos: photos
|
||||
.filter((p) => parseInt(p.picker_count, 10) > 0)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
filename: p.filename,
|
||||
original_filename: p.original_filename,
|
||||
url: `/admin/photos/${eventId}/photo/${p.id}`,
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${p.id}`,
|
||||
picker_count: parseInt(p.picker_count, 10),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching aggregate view:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aggregate view' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
|
||||
const rows = await db('guest_invites')
|
||||
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
|
||||
.where('guest_invites.event_id', eventId)
|
||||
.select(
|
||||
'guest_invites.id',
|
||||
'guest_invites.token',
|
||||
'guest_invites.created_at',
|
||||
'guest_invites.redeemed_at',
|
||||
'guest_invites.revoked_at',
|
||||
'gallery_guests.id as guest_id',
|
||||
'gallery_guests.name as guest_name',
|
||||
'gallery_guests.email as guest_email'
|
||||
)
|
||||
.orderBy('guest_invites.created_at', 'desc');
|
||||
|
||||
const invites = rows.map((r) => ({
|
||||
id: r.id,
|
||||
token: r.token,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
|
||||
created_at: r.created_at,
|
||||
redeemed_at: r.redeemed_at,
|
||||
revoked_at: r.revoked_at,
|
||||
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
|
||||
guest: {
|
||||
id: r.guest_id,
|
||||
name: r.guest_name,
|
||||
email: r.guest_email,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
logger.error('Error listing invites:', error);
|
||||
res.status(500).json({ error: 'Failed to list invites' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/invites — create guest + invite
|
||||
// Body: { name, email? }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/invites',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const name = String(req.body?.name || '').trim().slice(0, 100);
|
||||
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const inviteToken = crypto.randomBytes(24).toString('hex');
|
||||
|
||||
let guestId;
|
||||
let inviteId;
|
||||
await db.transaction(async (trx) => {
|
||||
const [guestRow] = await trx('gallery_guests')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
})
|
||||
.returning(['id']);
|
||||
guestId = guestRow.id;
|
||||
|
||||
const [inviteRow] = await trx('guest_invites')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
guest_id: guestId,
|
||||
token: inviteToken,
|
||||
created_by_admin_id: req.admin.id,
|
||||
})
|
||||
.returning(['id']);
|
||||
inviteId = inviteRow.id;
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_created',
|
||||
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
res.json({
|
||||
invite: {
|
||||
id: inviteId,
|
||||
token: inviteToken,
|
||||
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
|
||||
status: 'pending',
|
||||
guest: { id: guestId, name, email: email || null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating invite:', error);
|
||||
res.status(500).json({ error: 'Failed to create invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/invites/:inviteId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, inviteId } = req.params;
|
||||
const updated = await db('guest_invites')
|
||||
.where({ id: inviteId, event_id: eventId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Invite not found or already revoked' });
|
||||
}
|
||||
|
||||
await logActivity(
|
||||
'guest_invite_revoked',
|
||||
{ event_id: eventId, invite_id: inviteId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error revoking invite:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke invite' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
|
||||
// Query: format=txt|csv|json (default: csv)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/export-all',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
|
||||
|
||||
const guests = await db('gallery_guests')
|
||||
.where({ event_id: eventId, is_deleted: false })
|
||||
.select('id', 'name', 'email');
|
||||
|
||||
if (guests.length === 0) {
|
||||
return res.status(404).json({ error: 'No guests to export' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="event-${eventId}-guests.zip"`
|
||||
);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err) => {
|
||||
logger.error('Archive error:', err);
|
||||
res.status(500).end();
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
for (const g of guests) {
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', g.id)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
let body;
|
||||
if (format === 'json') {
|
||||
body = JSON.stringify({ guest: g, selections }, null, 2);
|
||||
} else if (format === 'csv') {
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
body = [header, ...rows].join('\n');
|
||||
} else {
|
||||
// txt — just filenames
|
||||
body = selections.map((s) => s.original_filename || s.filename).join('\n');
|
||||
}
|
||||
archive.append(body, { name: filename });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
logger.error('Error exporting all guests:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Failed to export guests' });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
|
||||
// (Phase 2)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const feedback = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.select(
|
||||
'photo_feedback.id as feedback_id',
|
||||
'photo_feedback.feedback_type',
|
||||
'photo_feedback.rating',
|
||||
'photo_feedback.comment_text',
|
||||
'photo_feedback.created_at',
|
||||
'photos.id as photo_id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.type'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
const photoFor = (row) => ({
|
||||
id: row.photo_id,
|
||||
filename: row.filename,
|
||||
original_filename: row.original_filename,
|
||||
type: row.type,
|
||||
url: `/admin/photos/${eventId}/photo/${row.photo_id}`,
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
|
||||
});
|
||||
|
||||
const selections = {
|
||||
liked: [],
|
||||
favorited: [],
|
||||
rated: [],
|
||||
commented: [],
|
||||
};
|
||||
for (const row of feedback) {
|
||||
if (row.feedback_type === 'like') {
|
||||
selections.liked.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'favorite') {
|
||||
selections.favorited.push(photoFor(row));
|
||||
} else if (row.feedback_type === 'rating') {
|
||||
selections.rated.push({ photo: photoFor(row), rating: row.rating });
|
||||
} else if (row.feedback_type === 'comment') {
|
||||
selections.commented.push({
|
||||
photo: photoFor(row),
|
||||
comment: row.comment_text,
|
||||
created_at: row.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
guest: {
|
||||
...serializeGuest(guest),
|
||||
stats: {
|
||||
likes: selections.liked.length,
|
||||
favorites: selections.favorited.length,
|
||||
comments: selections.commented.length,
|
||||
ratings: selections.rated.length,
|
||||
},
|
||||
},
|
||||
selections,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching guest detail:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch guest detail' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
|
||||
// Query: format=txt|csv|json
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/events/:eventId/guests/:guestId/export',
|
||||
adminAuth,
|
||||
requirePermission('events.view'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const selections = await db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.guest_id', guestId)
|
||||
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
|
||||
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
|
||||
|
||||
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
|
||||
const filename = `${safeName}.${format}`;
|
||||
|
||||
if (format === 'json') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
|
||||
}
|
||||
if (format === 'csv') {
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
const header = 'filename,original_filename,feedback_type';
|
||||
const rows = selections.map(
|
||||
(s) =>
|
||||
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
|
||||
);
|
||||
return res.send([header, ...rows].join('\n'));
|
||||
}
|
||||
// txt — one filename per line
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
|
||||
} catch (error) {
|
||||
logger.error('Error exporting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to export guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.delete(
|
||||
'/events/:eventId/guests/:guestId',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, guestId } = req.params;
|
||||
const guest = await loadGuestOr404(eventId, guestId, res);
|
||||
if (!guest) return;
|
||||
|
||||
const result = await feedbackService.anonymizeGuestFeedback(guestId);
|
||||
|
||||
await db('gallery_guests').where({ id: guestId }).update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await logActivity(
|
||||
'guest_deleted',
|
||||
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to delete guest' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
|
||||
// Body: { mergeIds: number[] }
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/events/:eventId/guests/:keepId/merge',
|
||||
adminAuth,
|
||||
requirePermission('events.edit'),
|
||||
requireEventOwnership,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { eventId, keepId } = req.params;
|
||||
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
|
||||
|
||||
if (mergeIds.length === 0) {
|
||||
return res.status(400).json({ error: 'mergeIds is required' });
|
||||
}
|
||||
if (mergeIds.includes(Number(keepId))) {
|
||||
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
|
||||
}
|
||||
|
||||
// Sanity check: all guests belong to this event.
|
||||
const all = await db('gallery_guests')
|
||||
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
|
||||
.where({ event_id: eventId });
|
||||
if (all.length !== mergeIds.length + 1) {
|
||||
return res.status(400).json({ error: 'All guests must belong to the same event' });
|
||||
}
|
||||
|
||||
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
|
||||
|
||||
// Soft-delete the merged (source) guests.
|
||||
await db('gallery_guests')
|
||||
.whereIn('id', mergeIds.map(Number))
|
||||
.update({ is_deleted: true, last_seen_at: db.fn.now() });
|
||||
|
||||
await logActivity(
|
||||
'guest_merged',
|
||||
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error merging guests:', error);
|
||||
res.status(500).json({ error: 'Failed to merge guests' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
isRunning: false,
|
||||
lastResult: null
|
||||
};
|
||||
|
||||
// Repair photo dimensions (background job)
|
||||
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
if (repairProgress.isRunning) {
|
||||
return res.status(409).json({ error: 'Repair is already running' });
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where(function () {
|
||||
this.whereNull('photos.width').orWhereNull('photos.height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
|
||||
})
|
||||
.select(
|
||||
'photos.id', 'photos.path', 'photos.filename',
|
||||
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
|
||||
'events.source_mode', 'events.external_path', 'events.slug'
|
||||
);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
}
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
message: `Started repairing dimensions for ${photos.length} photos`,
|
||||
count: photos.length
|
||||
});
|
||||
|
||||
// Process in background
|
||||
repairProgress.isRunning = true;
|
||||
repairProgress.lastResult = null;
|
||||
|
||||
setImmediate(async () => {
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
logger.error('Sharp not available for dimension repair:', err.message);
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
|
||||
let fullPath;
|
||||
try {
|
||||
fullPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (err) {
|
||||
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
});
|
||||
successCount++;
|
||||
|
||||
if (successCount % 50 === 0) {
|
||||
logger.info(`Dimension repair progress: ${successCount} updated...`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: successCount, failed: errorCount };
|
||||
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error starting dimension repair:', error);
|
||||
res.status(500).json({ error: 'Failed to start dimension repair' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dimension repair status
|
||||
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const withDimensions = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.whereNotNull('width')
|
||||
.whereNotNull('height')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const total = Number(totalPhotos.count);
|
||||
const withDims = Number(withDimensions.count);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
withDimensions: withDims,
|
||||
withoutDimensions: total - withDims,
|
||||
isRunning: repairProgress.isRunning,
|
||||
lastResult: repairProgress.lastResult
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching dimension repair status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -84,8 +84,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
),
|
||||
eventId
|
||||
);
|
||||
|
||||
@@ -5,13 +5,16 @@ const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -46,47 +49,55 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create a multer instance that uses dynamically resolved allowed MIME types.
|
||||
// The allowed types are fetched from the database once per request (before multer
|
||||
// processes files) and attached to req.allowedMimeTypes so that the fileFilter
|
||||
// callback can read them synchronously.
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
parts: 10000, // Maximum number of parts (fields + files)
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
// req.allowedMimeTypes is populated by the middleware that runs before multer
|
||||
const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||
cb(new Error('Invalid file type. Check allowed file types in system settings.'));
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
// Middleware to resolve allowed MIME types from settings before multer runs
|
||||
const resolveAllowedTypes = async (req, res, next) => {
|
||||
try {
|
||||
req.allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve allowed MIME types:', error);
|
||||
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
// Dynamic content validator middleware that reads allowed types from req
|
||||
const validateUploadContent = async (req, res, next) => {
|
||||
const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const validator = createFileUploadValidator({
|
||||
allowedTypes,
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
return validator(req, res, next);
|
||||
};
|
||||
|
||||
// Request timeout middleware for uploads
|
||||
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
@@ -110,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Upload photos for an event
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
@@ -160,7 +171,30 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
}
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
|
||||
// Enforce photo cap if set
|
||||
if (event.photo_cap && event.photo_cap > 0) {
|
||||
const existingPhotoCount = await db('photos')
|
||||
.where({ event_id: eventId })
|
||||
.count('id as count')
|
||||
.first();
|
||||
const currentCount = parseInt(existingPhotoCount.count) || 0;
|
||||
const newFilesCount = (req.files && req.files.length) || 0;
|
||||
if (currentCount + newFilesCount > event.photo_cap) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
@@ -175,8 +209,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
|
||||
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
|
||||
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
|
||||
|
||||
// Determine photo type and category name
|
||||
let photoType = 'individual'; // default
|
||||
@@ -251,16 +286,33 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
const finalPath = path.join(finalDestPath, newFilename);
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
|
||||
|
||||
|
||||
// Extract capture date from EXIF metadata
|
||||
let capturedAt = null;
|
||||
try {
|
||||
capturedAt = await extractCaptureDate(tempPath);
|
||||
} catch (exifError) {
|
||||
// Non-fatal - just log and continue without capture date
|
||||
console.log(`Could not extract EXIF date for ${file.originalname}`);
|
||||
}
|
||||
|
||||
// Determine media type
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname, // Preserve original filename for Lightroom export
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId, // Save the selected category
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
captured_at: capturedAt, // EXIF capture date (if available)
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -304,20 +356,67 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
// Generate thumbnail and extract metadata
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||
let thumbnailPath = null;
|
||||
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
if (isVideoFile) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
|
||||
if (photoId && result.metadata) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
duration: result.metadata.duration,
|
||||
video_codec: result.metadata.videoCodec,
|
||||
audio_codec: result.metadata.audioCodec,
|
||||
width: result.metadata.width,
|
||||
height: result.metadata.height
|
||||
});
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path and image dimensions
|
||||
if (photoId) {
|
||||
const updateData = {};
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(operation.finalPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update(updateData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking, images only)
|
||||
if (photoId && !isVideoFile) {
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
@@ -424,7 +523,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
});
|
||||
|
||||
// Delete a photo
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -461,7 +560,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete pre-generated watermark if exists
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
|
||||
@@ -481,10 +585,10 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
});
|
||||
|
||||
// Update a photo (e.g., change category)
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
const { category_id, visibility } = req.body;
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
@@ -498,6 +602,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(visibility)) {
|
||||
updateData.visibility = visibility;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
@@ -537,7 +648,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
});
|
||||
|
||||
// Bulk delete photos
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds } = req.body;
|
||||
@@ -582,8 +693,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pre-generated watermark
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete from database
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
@@ -605,7 +721,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
});
|
||||
|
||||
// Bulk update photos
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
@@ -626,9 +742,14 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
const updateData = {};
|
||||
|
||||
// Handle visibility update (#172)
|
||||
if (updates.visibility !== undefined) {
|
||||
if (['visible', 'hidden'].includes(updates.visibility)) {
|
||||
updateData.visibility = updates.visibility;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
@@ -663,7 +784,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
});
|
||||
|
||||
// Download a photo
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
|
||||
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -695,37 +816,81 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
});
|
||||
|
||||
// Get all photos for an event
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating } = req.query;
|
||||
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
|
||||
const logic = req.query.logic === 'OR' ? 'OR' : 'AND';
|
||||
|
||||
let query = db('photos')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
|
||||
// Filter by category_id
|
||||
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
// Legacy type-based filtering
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
} else if (category_id === 'uncategorized') {
|
||||
// Filter for photos with no category assigned
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else {
|
||||
// Numeric category ID from photo_categories table
|
||||
const numericCategoryId = parseInt(category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
query = query.where({ 'photos.category_id': numericCategoryId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Keep type filter for backwards compatibility
|
||||
if (type) {
|
||||
query = query.where({ 'photos.type': type });
|
||||
}
|
||||
|
||||
|
||||
// Search by filename
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
query = query.where('photos.filename', 'like', `%${escapedSearch}%`);
|
||||
}
|
||||
|
||||
|
||||
// Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic
|
||||
const feedbackConditions = [];
|
||||
if (has_likes === 'true' || has_likes === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.like_count', '>', 0));
|
||||
}
|
||||
if (has_favorites === 'true' || has_favorites === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.favorite_count', '>', 0));
|
||||
}
|
||||
if (has_comments === 'true' || has_comments === true) {
|
||||
feedbackConditions.push(qb => qb.where('photos.comment_count', '>', 0));
|
||||
}
|
||||
if (min_rating !== undefined && min_rating !== null && min_rating !== '') {
|
||||
const minRatingNum = parseFloat(min_rating);
|
||||
if (!isNaN(minRatingNum)) {
|
||||
feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum));
|
||||
}
|
||||
}
|
||||
if (feedbackConditions.length > 0) {
|
||||
if (logic === 'OR') {
|
||||
query = query.where(builder => {
|
||||
feedbackConditions.forEach((cond, idx) => {
|
||||
if (idx === 0) {
|
||||
cond(builder);
|
||||
} else {
|
||||
builder.orWhere(sub => cond(sub));
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
feedbackConditions.forEach(cond => {
|
||||
query = query.where(builder => cond(builder));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let orderByColumn = 'photos.uploaded_at';
|
||||
if (sort === 'name') {
|
||||
@@ -755,6 +920,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename || null,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
@@ -763,6 +929,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.pc_slug || photo.type,
|
||||
media_type: photo.media_type || 'image',
|
||||
mime_type: photo.mime_type || null,
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
duration: photo.duration || null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
@@ -780,7 +951,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
});
|
||||
|
||||
// Serve photo with admin authentication
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -817,7 +988,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
});
|
||||
|
||||
// Serve thumbnail with admin authentication
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
@@ -857,7 +1028,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
@@ -883,7 +1054,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async
|
||||
// ============================================
|
||||
|
||||
// Initialize a chunked upload
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
@@ -921,7 +1092,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
});
|
||||
|
||||
// Upload a chunk
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
@@ -942,7 +1113,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
});
|
||||
|
||||
// Complete chunked upload and process the file
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, uploadId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
@@ -984,7 +1155,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
});
|
||||
|
||||
// Get upload status
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
@@ -1002,7 +1173,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
|
||||
});
|
||||
|
||||
// Abort chunked upload
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -100,19 +102,31 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
@@ -127,23 +141,35 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', type)
|
||||
.select('*');
|
||||
|
||||
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
@@ -196,6 +222,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
hide_powered_by
|
||||
} = req.body;
|
||||
|
||||
// Get current watermark settings hash for change detection
|
||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||
|
||||
const brandingSettings = {
|
||||
company_name,
|
||||
company_tagline,
|
||||
@@ -306,7 +335,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
// Check if watermark settings changed and trigger regeneration
|
||||
const newSettingsHash = await watermarkService.getSettingsHash();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (oldSettingsHash !== newSettingsHash) {
|
||||
// Clear watermark cache
|
||||
watermarkService.clearCache();
|
||||
|
||||
// Check if watermarking is now enabled or settings changed
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
// Start background regeneration of all watermarks
|
||||
console.log('Watermark settings changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
} else {
|
||||
// Watermarking was disabled, clear all pre-generated watermarks
|
||||
console.log('Watermarking disabled, clearing pre-generated watermarks');
|
||||
watermarkGeneratorService.clearAllWatermarks()
|
||||
.catch(err => console.error('Failed to clear watermarks:', err));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Branding settings updated successfully',
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
@@ -441,9 +503,27 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
// Trigger watermark regeneration since the logo changed
|
||||
watermarkService.clearCache();
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
console.log('Watermark logo changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Watermark logo uploaded successfully',
|
||||
watermarkLogoUrl: publicPath
|
||||
watermarkLogoUrl: publicPath,
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Watermark logo upload error:', error);
|
||||
@@ -663,6 +743,70 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
}
|
||||
});
|
||||
|
||||
// Update SEO settings
|
||||
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
|
||||
// Validate seo_blocked_ai_agents is an array of strings
|
||||
if (settings.seo_blocked_ai_agents !== undefined) {
|
||||
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
|
||||
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
|
||||
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate seo_custom_rules structure
|
||||
if (settings.seo_custom_rules !== undefined) {
|
||||
if (!Array.isArray(settings.seo_custom_rules)) {
|
||||
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
|
||||
}
|
||||
for (const rule of settings.seo_custom_rules) {
|
||||
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
|
||||
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
|
||||
}
|
||||
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
|
||||
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'seo',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: JSON.stringify(value),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Clear robots.txt cache
|
||||
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
|
||||
clearRobotsTxtCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'seo_settings_updated',
|
||||
actor_type: 'admin',
|
||||
actor_id: req.admin.id,
|
||||
actor_name: req.admin.username,
|
||||
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||
});
|
||||
|
||||
res.json({ message: 'SEO settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('SEO settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update SEO settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get storage info
|
||||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,12 @@ const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||
const {
|
||||
checkAndNotifyUpdates,
|
||||
sendUpdateNotificationNow,
|
||||
getUpdateNotificationSettings
|
||||
} = require('../services/updateNotificationService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -65,6 +71,47 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
|
||||
}
|
||||
});
|
||||
|
||||
// Get update instructions for current environment
|
||||
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Check if update checking is enabled
|
||||
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
||||
|
||||
if (!updateCheckEnabled) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
message: 'Update checking is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
const env = await detectEnvironment();
|
||||
const updateInfo = await checkForUpdates();
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
return res.json({
|
||||
updateAvailable: false,
|
||||
currentVersion: updateInfo.current,
|
||||
message: 'You are running the latest version'
|
||||
});
|
||||
}
|
||||
|
||||
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
|
||||
|
||||
res.json({
|
||||
updateAvailable: true,
|
||||
currentVersion: updateInfo.current,
|
||||
targetVersion: updateInfo.latest.forChannel,
|
||||
channel: updateInfo.channel,
|
||||
environment: env,
|
||||
instructions,
|
||||
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error generating update instructions:', error);
|
||||
res.status(500).json({ error: 'Failed to generate update instructions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -262,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
|
||||
}
|
||||
});
|
||||
|
||||
// Get update notification settings
|
||||
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching update notification settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch update notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification settings
|
||||
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const { enabled, recipients } = req.body;
|
||||
|
||||
if (typeof enabled !== 'undefined') {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'update_email_notifications_enabled')
|
||||
.update({
|
||||
setting_value: JSON.stringify(enabled === true),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof recipients !== 'undefined') {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'update_email_recipients')
|
||||
.update({
|
||||
setting_value: JSON.stringify(recipients || ''),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSettings = await getUpdateNotificationSettings();
|
||||
res.json({ success: true, settings: updatedSettings });
|
||||
} catch (error) {
|
||||
logger.error('Error updating notification settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually trigger update notification email
|
||||
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const result = await sendUpdateNotificationNow();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error sending update notification:', error);
|
||||
res.status(500).json({ error: 'Failed to send update notification' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check and send update notifications (called on admin login or periodically)
|
||||
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const result = await checkAndNotifyUpdates();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error checking for update notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to check for update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -10,27 +10,33 @@ const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Parse JSON-encoded setting values
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
try { return JSON.parse(value); } catch (e) { return value; }
|
||||
}
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('key', 'value', 'description');
|
||||
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.key] = {
|
||||
value: s.value,
|
||||
description: s.description
|
||||
const parsed = parseSettingValue(s.setting_value);
|
||||
settingsMap[s.setting_key] = {
|
||||
value: String(parsed ?? '')
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
settings: settingsMap,
|
||||
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
|
||||
@@ -66,17 +72,17 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
|
||||
|
||||
// Update settings
|
||||
const updates = [];
|
||||
if (width) updates.push({ key: 'thumbnail_width', value: width.toString() });
|
||||
if (height) updates.push({ key: 'thumbnail_height', value: height.toString() });
|
||||
if (fit) updates.push({ key: 'thumbnail_fit', value: fit });
|
||||
if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() });
|
||||
if (format) updates.push({ key: 'thumbnail_format', value: format });
|
||||
|
||||
if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width });
|
||||
if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height });
|
||||
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
|
||||
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
|
||||
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
|
||||
|
||||
for (const update of updates) {
|
||||
await db('app_settings')
|
||||
.where('key', update.key)
|
||||
.where('setting_key', update.setting_key)
|
||||
.update({
|
||||
value: update.value,
|
||||
setting_value: update.setting_value,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,7 +114,8 @@ router.post('/invite', [
|
||||
const invitation = await userManagementService.createInvitation({
|
||||
email: req.body.email,
|
||||
roleId: req.body.role_id,
|
||||
invitedById: req.admin.id
|
||||
invitedById: req.admin.id,
|
||||
inviterRoleName: req.admin.roleName
|
||||
});
|
||||
|
||||
successResponse(res, { invitation }, 201);
|
||||
@@ -146,7 +147,12 @@ router.get('/:id', [
|
||||
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
|
||||
const targetId = parseInt(req.params.id);
|
||||
// Non-super_admin users can only view their own profile
|
||||
if (req.admin.roleName !== 'super_admin' && targetId !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
const user = await userManagementService.getAdminUserById(targetId);
|
||||
res.json({ user: transformUser(user) });
|
||||
}));
|
||||
|
||||
@@ -169,7 +175,8 @@ router.put('/:id', [
|
||||
const user = await userManagementService.updateAdminUser(
|
||||
parseInt(req.params.id),
|
||||
req.body,
|
||||
req.admin.id
|
||||
req.admin.id,
|
||||
{ roleName: req.admin.roleName }
|
||||
);
|
||||
|
||||
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
|
||||
|
||||
+112
-8
@@ -13,6 +13,7 @@ const {
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const logger = require('../utils/logger');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
@@ -117,9 +118,8 @@ router.post('/admin/login', [
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Include role in response
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
@@ -145,7 +145,8 @@ router.post('/logout', async (req, res) => {
|
||||
const token = adminToken || galleryToken;
|
||||
|
||||
if (token) {
|
||||
// End the session
|
||||
// Revoke the token so it can't be reused, then end the session
|
||||
await revokeToken(token, 'user_logout');
|
||||
endSession(token);
|
||||
|
||||
try {
|
||||
@@ -198,6 +199,8 @@ router.post('/gallery/verify', [
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
|
||||
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
@@ -280,7 +283,8 @@ router.post('/gallery/verify', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -289,6 +293,82 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
|
||||
// Client access login (PIN-based)
|
||||
router.post('/gallery/:slug/client-login', [
|
||||
body('password').notEmpty().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { slug } = req.params;
|
||||
const { password } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event || !event.client_access_enabled || !event.client_password_hash) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.client_password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
type: 'gallery',
|
||||
accessLevel: 'client',
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setGalleryAuthCookies(res, token, event.slug);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
event: {
|
||||
id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
event_date: event.event_date,
|
||||
welcome_message: event.welcome_message,
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: true
|
||||
},
|
||||
accessLevel: 'client'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Client login error:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Share link authentication (token-based)
|
||||
router.post('/gallery/share-login', [
|
||||
body('slug').notEmpty().trim(),
|
||||
@@ -304,6 +384,17 @@ router.post('/gallery/share-login', [
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Rate limit share-link login attempts
|
||||
const shareIdentifier = `gallery:${slug}:share`;
|
||||
const lockoutStatus = await checkAccountLockout(shareIdentifier, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Share link login attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
@@ -316,12 +407,14 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
@@ -353,7 +446,8 @@ router.post('/gallery/share-login', [
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
require_password: requiresPassword,
|
||||
photo_cap: event.photo_cap
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -362,10 +456,14 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
});
|
||||
|
||||
// Gallery logout to clear cookies
|
||||
// Gallery logout to clear cookies and revoke token
|
||||
router.post('/gallery/logout', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.body || {};
|
||||
const token = getGalleryTokenFromRequest(req, slug);
|
||||
if (token) {
|
||||
await revokeToken(token, 'gallery_logout');
|
||||
}
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
@@ -386,11 +484,17 @@ router.get('/session', async (req, res) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
|
||||
// Check if token has been revoked (e.g. after logout)
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
type: decoded.type,
|
||||
|
||||
@@ -11,6 +11,7 @@ const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
|
||||
+414
-58
@@ -5,17 +5,18 @@ const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
@@ -73,7 +74,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
@@ -107,13 +108,23 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'allow_user_uploads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering'
|
||||
'use_canvas_rendering',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor',
|
||||
'is_draft',
|
||||
'default_photo_sort'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -129,11 +140,16 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
|
||||
// Check if event is archived
|
||||
if (event.is_archived) {
|
||||
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
|
||||
}
|
||||
|
||||
// Check if event is a draft (allow admin preview)
|
||||
if (event.is_draft && !isAdminPreview(req)) {
|
||||
return res.status(404).json({ error: 'Gallery is not yet published' });
|
||||
}
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
@@ -151,15 +167,24 @@ router.get('/:slug/info', async (req, res) => {
|
||||
event_date: event.event_date,
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
hero_divider_style: event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -170,14 +195,42 @@ router.get('/:slug/info', async (req, res) => {
|
||||
// Get all photos
|
||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
// Get filter and sort parameters from query
|
||||
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const wmVersion = watermarkSettings?.enabled
|
||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '';
|
||||
|
||||
// Build the query with sorting
|
||||
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||
const isClient = req.accessLevel === 'client';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
.select('photos.*')
|
||||
.orderBy('photos.uploaded_at', 'desc');
|
||||
.select('photos.*');
|
||||
|
||||
// Guests only see visible photos; clients see all
|
||||
if (!isClient) {
|
||||
photosQuery = photosQuery.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
|
||||
} else if (sort === 'filename') {
|
||||
photosQuery = photosQuery.orderBy('photos.filename', sortOrder);
|
||||
} else {
|
||||
// Default: sort by upload date
|
||||
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
let photos = await photosQuery;
|
||||
|
||||
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||
if (filter) {
|
||||
@@ -258,6 +311,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if feedback should be visible to guests
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
|
||||
const showFeedbackToGuests = isClient || feedbackSettings.show_feedback_to_guests !== false;
|
||||
|
||||
// Then get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -273,20 +331,36 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
// Get distinct photo types for this event
|
||||
const categoryResults = await db('photos')
|
||||
// Get actual categories used by photos in this event
|
||||
// This includes both global categories and event-specific ones
|
||||
const usedCategoryIds = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.select('type')
|
||||
.distinct('type')
|
||||
.orderBy('type', 'asc');
|
||||
|
||||
// Convert types to category-like objects
|
||||
const categories = categoryResults.map(result => ({
|
||||
id: result.type,
|
||||
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
slug: result.type,
|
||||
is_global: false
|
||||
}));
|
||||
.whereNotNull('category_id')
|
||||
.distinct('category_id')
|
||||
.pluck('category_id');
|
||||
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
is_global: cat.is_global,
|
||||
hero_photo_id: cat.hero_photo_id || null
|
||||
}));
|
||||
}
|
||||
|
||||
// Build a map for quick category lookup
|
||||
const categoryMap = {};
|
||||
categories.forEach(cat => {
|
||||
categoryMap[cat.id] = cat;
|
||||
});
|
||||
|
||||
// Log view
|
||||
await db('access_logs').insert({
|
||||
@@ -317,41 +391,65 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
expires_at: req.event.expires_at,
|
||||
hero_photo_id: req.event.hero_photo_id,
|
||||
allow_downloads: req.event.allow_downloads !== false,
|
||||
allow_user_uploads: req.event.allow_user_uploads === true,
|
||||
disable_right_click: req.event.disable_right_click === true,
|
||||
watermark_downloads: req.event.watermark_downloads === true,
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
hero_logo_url: req.event.hero_logo_url || null,
|
||||
header_style: req.event.header_style || 'standard',
|
||||
hero_divider_style: req.event.hero_divider_style || 'wave',
|
||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
// Add watermark version to URLs for cache busting when settings change
|
||||
const wmQuery = wmVersion ? `?${wmVersion}` : '';
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: photoUrl,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
||||
// Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
category_id: photo.category_id || null,
|
||||
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
|
||||
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
// EXIF capture date
|
||||
captured_at: photo.captured_at || null,
|
||||
// Media type
|
||||
media_type: photo.media_type || null,
|
||||
mime_type: photo.mime_type || null,
|
||||
duration: photo.duration || null,
|
||||
// Feedback data (hidden when show_feedback_to_guests is disabled)
|
||||
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
|
||||
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
|
||||
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
|
||||
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
|
||||
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
|
||||
// Visibility (only included for clients)
|
||||
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -361,24 +459,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoId } = req.params;
|
||||
const { visibility } = req.body;
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk toggle photo visibility (client-only)
|
||||
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
if (req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Client access required' });
|
||||
}
|
||||
|
||||
const { photoIds, visibility } = req.body;
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
if (!['visible', 'hidden'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility value' });
|
||||
}
|
||||
|
||||
const count = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', req.event.id)
|
||||
.update({ visibility });
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error bulk updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download single photo
|
||||
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
// Check if downloads are allowed for this event
|
||||
if (req.event.allow_downloads === false) {
|
||||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||
}
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -677,11 +842,15 @@ router.get('/:slug/photo/:photoId',
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
@@ -699,8 +868,34 @@ router.get('/:slug/photo/:photoId',
|
||||
|
||||
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const fs = require('fs');
|
||||
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
photoPath: photo.path,
|
||||
photoFilename: photo.filename
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Verify file exists before attempting to serve
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error('Photo file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: filePath,
|
||||
photoPath: photo.path
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
@@ -712,14 +907,13 @@ router.get('/:slug/photo/:photoId',
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
@@ -754,13 +948,52 @@ router.get('/:slug/photo/:photoId',
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, modification time, and watermark settings
|
||||
// This ensures cache invalidation when watermark settings change
|
||||
const stat = fs.statSync(filePath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
// Try to serve pre-generated watermarked file for instant loading
|
||||
if (photo.watermark_path) {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
try {
|
||||
// Check if pre-generated watermark file exists
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
return res.sendFile(watermarkFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// File doesn't exist or error, fall through to on-the-fly generation
|
||||
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
|
||||
// Also queue regeneration for next time
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Queue watermark generation in background for next request
|
||||
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
@@ -769,6 +1002,7 @@ router.get('/:slug/photo/:photoId',
|
||||
// Send original file with basic protection headers
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
@@ -802,6 +1036,11 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
@@ -820,18 +1059,32 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(thumbPath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true'
|
||||
'X-Protected-Thumbnail': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark to thumbnail
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
|
||||
@@ -851,6 +1104,97 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
}
|
||||
);
|
||||
|
||||
// Serve hero-optimized image (1920x1080 for full-width hero sections)
|
||||
router.get('/:slug/hero/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Block guest access to hidden photos
|
||||
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
|
||||
return res.status(403).json({ error: 'Photo not available' });
|
||||
}
|
||||
|
||||
// Check if this is a video - videos don't get hero images
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
if (isVideo) {
|
||||
// For videos, redirect to the regular photo endpoint
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
// Ensure hero image exists and is valid, regenerate if needed
|
||||
const heroPath = await ensureHeroImage(photo);
|
||||
|
||||
if (!heroPath) {
|
||||
// If hero generation fails, fall back to original photo
|
||||
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
const heroFullPath = path.join(getStoragePath(), heroPath);
|
||||
const fs = require('fs');
|
||||
|
||||
// Verify file exists before attempting to serve
|
||||
if (!fs.existsSync(heroFullPath)) {
|
||||
logger.error('Hero image file does not exist at resolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
resolvedPath: heroFullPath
|
||||
});
|
||||
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
|
||||
}
|
||||
|
||||
// Get file stats for ETag
|
||||
const stat = fs.statSync(heroFullPath);
|
||||
const etag = `"hero-${photoId}-${stat.mtime.getTime()}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Check if watermarks should be applied
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Hero-Image': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark to hero image
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(heroFullPath, watermarkSettings);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send hero image without watermark
|
||||
res.sendFile(path.resolve(heroFullPath));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving hero image:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
// Fall back to original photo on any error
|
||||
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get feedback settings for gallery
|
||||
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
@@ -860,10 +1204,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
res.json({
|
||||
feedback_enabled: settings.feedback_enabled || false,
|
||||
allow_ratings: settings.allow_ratings,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_likes: settings.allow_likes,
|
||||
allow_comments: settings.allow_comments,
|
||||
allow_favorites: settings.allow_favorites,
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||
show_feedback_to_guests: settings.show_feedback_to_guests,
|
||||
require_name_email: settings.require_name_email || false,
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
@@ -936,6 +1282,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const { getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Resolve allowed MIME types from settings
|
||||
let allowedMimeTypes;
|
||||
try {
|
||||
allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch {
|
||||
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
@@ -943,8 +1300,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
files: 10 // Max 10 files at once
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const { photoAuth } = require('../middleware/photoAuth');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const feedbackModeration = require('../services/feedbackModeration');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
|
||||
try {
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
// Only send relevant settings to guests
|
||||
// Convert SQLite boolean values (0/1) to proper booleans
|
||||
const guestSettings = {
|
||||
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
|
||||
allow_comments: Boolean(settings.allow_comments),
|
||||
allow_favorites: Boolean(settings.allow_favorites),
|
||||
require_name_email: Boolean(settings.require_name_email),
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
|
||||
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
};
|
||||
|
||||
|
||||
res.json(guestSettings);
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
|
||||
// Get feedback for a specific photo
|
||||
router.get('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
checkValidation,
|
||||
async (req, res) => {
|
||||
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
|
||||
// Submit feedback for a photo
|
||||
router.post('/:slug/photos/:photoId/feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
validatePhotoId,
|
||||
validateFeedbackSubmission,
|
||||
checkValidation,
|
||||
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Get feedback settings
|
||||
|
||||
// Get feedback settings first so we can enforce identity_mode.
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
|
||||
}
|
||||
|
||||
|
||||
// In guest identity mode, a valid guest token is required. The server
|
||||
// never trusts guest_name/guest_email from the body in this mode — it
|
||||
// reads them from the verified token via req.guest.
|
||||
if (settings.identity_mode === 'guest') {
|
||||
if (!req.guest || req.guest.eventId !== event.id) {
|
||||
return res.status(401).json({
|
||||
error: 'Guest identity required',
|
||||
code: 'GUEST_IDENTITY_REQUIRED'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
// Check if specific feedback type is allowed
|
||||
const feedbackType = req.body.feedback_type;
|
||||
const typeAllowed = {
|
||||
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
comment: settings.allow_comments,
|
||||
favorite: settings.allow_favorites
|
||||
};
|
||||
|
||||
|
||||
if (!typeAllowed[feedbackType]) {
|
||||
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
|
||||
}
|
||||
|
||||
|
||||
// Verify photo belongs to event
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: event.id })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Validate guest requirements
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
|
||||
// Validate guest requirements only in simple mode. In guest mode, the
|
||||
// identity is already provided via the token and verified above.
|
||||
if (settings.identity_mode !== 'guest') {
|
||||
const guestValidation = await validateGuestRequirements(settings, req.body);
|
||||
if (!guestValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Guest information required',
|
||||
errors: guestValidation.errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply rate limiting based on feedback type
|
||||
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -192,19 +212,21 @@ router.post('/:slug/photos/:photoId/feedback',
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// If we got here and response was sent (rate limited), return
|
||||
if (res.headersSent) return;
|
||||
|
||||
// Prepare feedback data
|
||||
|
||||
// Prepare feedback data. In guest mode, use the verified token as the
|
||||
// source of truth for name/email — never the body.
|
||||
const feedbackData = {
|
||||
feedback_type: feedbackType,
|
||||
rating: req.body.rating,
|
||||
comment_text: req.body.comment_text,
|
||||
guest_name: req.body.guest_name,
|
||||
guest_email: req.body.guest_email,
|
||||
guest_name: req.guest?.name ?? req.body.guest_name,
|
||||
guest_email: req.guest?.email ?? req.body.guest_email,
|
||||
guest_id: req.guest?.id ?? null,
|
||||
ip_address: req.ip || req.connection.remoteAddress,
|
||||
user_agent: req.headers['user-agent'],
|
||||
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
|
||||
moderate_comments: settings.moderate_comments
|
||||
};
|
||||
|
||||
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
|
||||
// Get user's own feedback for all photos
|
||||
router.get('/:slug/my-feedback',
|
||||
verifyGalleryAccess,
|
||||
resolveGuest,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const event = req.event;
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
|
||||
const myFeedback = await db('photo_feedback')
|
||||
|
||||
const query = db('photo_feedback')
|
||||
.join('photos', 'photo_feedback.photo_id', 'photos.id')
|
||||
.where('photo_feedback.event_id', event.id)
|
||||
.where('photo_feedback.guest_identifier', guestIdentifier)
|
||||
.where('photo_feedback.event_id', event.id);
|
||||
|
||||
// Prefer guest_id lookup when a verified guest token is present
|
||||
// (per-person identity). Fall back to the device hash otherwise.
|
||||
if (req.guest?.id) {
|
||||
query.where('photo_feedback.guest_id', req.guest.id);
|
||||
} else {
|
||||
const guestIdentifier = generateGuestIdentifier(req);
|
||||
query.where('photo_feedback.guest_identifier', guestIdentifier);
|
||||
}
|
||||
|
||||
const myFeedback = await query
|
||||
.select(
|
||||
'photo_feedback.*',
|
||||
'photos.filename',
|
||||
'photos.path'
|
||||
)
|
||||
.orderBy('photo_feedback.created_at', 'desc');
|
||||
|
||||
|
||||
res.json(myFeedback);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user feedback:', error);
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const guestRecovery = require('../services/guestRecoveryService');
|
||||
|
||||
const MAX_NAME_LEN = 100;
|
||||
const MAX_EMAIL_LEN = 255;
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// In-memory rate limit for guest registration (20 per hour per IP). Simple
|
||||
// sliding window; on process restart the counters reset which is acceptable.
|
||||
const registrationAttempts = new Map();
|
||||
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
|
||||
const REGISTRATION_MAX = 20;
|
||||
|
||||
function checkRegistrationRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
registrationAttempts.set(ip, entry);
|
||||
return entry.count <= REGISTRATION_MAX;
|
||||
}
|
||||
|
||||
function sanitizeName(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
// Strip HTML/control chars, collapse whitespace.
|
||||
const cleaned = value
|
||||
.replace(/[<>&"']/g, '')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return cleaned.slice(0, MAX_NAME_LEN);
|
||||
}
|
||||
|
||||
function sanitizeEmail(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest
|
||||
* Body: { name, email? }
|
||||
*
|
||||
* Registers a new per-person guest identity for this gallery. Returns a JWT
|
||||
* that the frontend must send as the x-guest-token header on subsequent
|
||||
* feedback requests.
|
||||
*/
|
||||
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
if (!checkRegistrationRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many registration attempts' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
|
||||
// Guest registration is only meaningful when feedback is enabled.
|
||||
if (!settings.feedback_enabled) {
|
||||
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
|
||||
}
|
||||
|
||||
const name = sanitizeName(req.body?.name);
|
||||
if (!name || name.length < 1) {
|
||||
return res.status(400).json({ error: 'Name is required', field: 'name' });
|
||||
}
|
||||
|
||||
let email = sanitizeEmail(req.body?.email);
|
||||
if (email && !EMAIL_REGEX.test(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
|
||||
}
|
||||
if (settings.require_name_email && !email) {
|
||||
return res.status(400).json({ error: 'Email is required', field: 'email' });
|
||||
}
|
||||
|
||||
const identifier = crypto.randomUUID();
|
||||
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
|
||||
|
||||
const [row] = await db('gallery_guests')
|
||||
.insert({
|
||||
event_id: event.id,
|
||||
name,
|
||||
email: email || null,
|
||||
identifier,
|
||||
ip_address_last: ip.substring(0, 45),
|
||||
user_agent_last: userAgent,
|
||||
})
|
||||
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: row.id,
|
||||
eventId: event.id,
|
||||
identifier: row.identifier,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
logger.info('Guest registered', {
|
||||
eventId: event.id,
|
||||
guestId: row.id,
|
||||
name: row.name,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
identifier: row.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest registration failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to register guest' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /gallery/:slug/guest/me
|
||||
* Returns the current guest profile from a valid guest token. 401 otherwise.
|
||||
*/
|
||||
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
// Update last_seen_at on each profile fetch (cheap and useful for admin).
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: req.guest.id,
|
||||
name: req.guest.name,
|
||||
email: req.guest.email,
|
||||
identifier: req.guest.identifier,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest profile fetch failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to fetch guest profile' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /gallery/:slug/guest/me
|
||||
*
|
||||
* "Forget me" — soft-deletes the guest row and anonymizes their feedback so
|
||||
* aggregate counts remain stable but personal data is removed.
|
||||
*/
|
||||
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
|
||||
try {
|
||||
if (req.guest.eventId !== req.event.id) {
|
||||
return res.status(403).json({ error: 'Guest token does not match gallery' });
|
||||
}
|
||||
|
||||
await feedbackService.anonymizeGuestFeedback(req.guest.id);
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: req.guest.id })
|
||||
.update({
|
||||
is_deleted: true,
|
||||
name: 'Removed',
|
||||
email: null,
|
||||
last_seen_at: db.fn.now(),
|
||||
});
|
||||
|
||||
logger.info('Guest self-forgot', {
|
||||
eventId: req.event.id,
|
||||
guestId: req.guest.id,
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest forget-me failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to forget guest' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.2 — Email-based identity recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
|
||||
const recoveryAttempts = new Map();
|
||||
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFY_MAX = 20;
|
||||
function checkRecoveryRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
|
||||
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
|
||||
entry.count = 0;
|
||||
entry.windowStart = now;
|
||||
}
|
||||
entry.count += 1;
|
||||
recoveryAttempts.set(ip, entry);
|
||||
return entry.count <= VERIFY_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/recover
|
||||
* Body: { email }
|
||||
*
|
||||
* Sends a 6-digit code to the email if it matches an existing guest. Returns
|
||||
* 200 regardless of whether a matching guest exists (prevents enumeration).
|
||||
*/
|
||||
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many recovery attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
if (!email || !EMAIL_REGEX.test(email)) {
|
||||
// Still return 200 to avoid leaking validity of the email field.
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const settings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
|
||||
if (guest) {
|
||||
try {
|
||||
const code = await guestRecovery.createCode(event.id, email);
|
||||
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
|
||||
} catch (sendError) {
|
||||
logger.error('Failed to send recovery email', { error: sendError.message });
|
||||
// Still return 200 so clients can't distinguish failures.
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Guest recovery request failed', { error: error.message });
|
||||
return res.json({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/verify
|
||||
* Body: { email, code }
|
||||
*
|
||||
* Exchanges a valid verification code for a guest token. Reuses the existing
|
||||
* guest row associated with the email (the guest continues where they left
|
||||
* off, cross-device).
|
||||
*/
|
||||
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (!checkRecoveryRate(ip)) {
|
||||
return res.status(429).json({ error: 'Too many verification attempts' });
|
||||
}
|
||||
|
||||
const email = sanitizeEmail(req.body?.email);
|
||||
const code = String(req.body?.code || '').trim();
|
||||
if (!email || !code) {
|
||||
return res.status(400).json({ error: 'Email and code are required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
|
||||
if (!verifyResult.ok) {
|
||||
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
|
||||
}
|
||||
|
||||
const guest = await db('gallery_guests')
|
||||
.where({ event_id: event.id, email, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) {
|
||||
return res.status(404).json({ error: 'Guest not found' });
|
||||
}
|
||||
|
||||
await db('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
email_verified_at: guest.email_verified_at || db.fn.now(),
|
||||
last_seen_at: db.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
});
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: guest.id,
|
||||
eventId: event.id,
|
||||
identifier: guest.identifier,
|
||||
name: guest.name,
|
||||
});
|
||||
|
||||
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: guest.id,
|
||||
name: guest.name,
|
||||
email: guest.email,
|
||||
identifier: guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Guest verify failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to verify code' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3.3 — Invite token redemption
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /gallery/:slug/guest/redeem
|
||||
* Body: { inviteToken }
|
||||
*
|
||||
* Redeems a pre-minted invite token (created by admin). Single use.
|
||||
*/
|
||||
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const inviteToken = String(req.body?.inviteToken || '').trim();
|
||||
if (!inviteToken) {
|
||||
return res.status(400).json({ error: 'Invite token required' });
|
||||
}
|
||||
|
||||
const event = req.event;
|
||||
|
||||
const result = await db.transaction(async (trx) => {
|
||||
const invite = await trx('guest_invites')
|
||||
.where({ token: inviteToken, event_id: event.id })
|
||||
.first();
|
||||
if (!invite) return { error: 'not_found' };
|
||||
if (invite.revoked_at) return { error: 'revoked' };
|
||||
if (invite.redeemed_at) return { error: 'already_redeemed' };
|
||||
|
||||
const guest = await trx('gallery_guests')
|
||||
.where({ id: invite.guest_id, is_deleted: false })
|
||||
.first();
|
||||
if (!guest) return { error: 'guest_missing' };
|
||||
|
||||
await trx('guest_invites')
|
||||
.where({ id: invite.id })
|
||||
.update({ redeemed_at: trx.fn.now() });
|
||||
|
||||
await trx('gallery_guests')
|
||||
.where({ id: guest.id })
|
||||
.update({
|
||||
last_seen_at: trx.fn.now(),
|
||||
ip_address_last: (req.ip || '').substring(0, 45),
|
||||
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
|
||||
});
|
||||
|
||||
return { guest };
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const statusMap = {
|
||||
not_found: 404,
|
||||
revoked: 410,
|
||||
already_redeemed: 409,
|
||||
guest_missing: 404,
|
||||
};
|
||||
return res.status(statusMap[result.error] || 400).json({ error: result.error });
|
||||
}
|
||||
|
||||
const token = signGuestToken({
|
||||
guestId: result.guest.id,
|
||||
eventId: event.id,
|
||||
identifier: result.guest.identifier,
|
||||
name: result.guest.name,
|
||||
});
|
||||
|
||||
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
|
||||
|
||||
return res.json({
|
||||
guest: {
|
||||
id: result.guest.id,
|
||||
name: result.guest.name,
|
||||
email: result.guest.email,
|
||||
identifier: result.guest.identifier,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Invite redemption failed', { error: error.message });
|
||||
return res.status(500).json({ error: 'Failed to redeem invite' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -12,7 +12,8 @@ router.get('/', async (req, res) => {
|
||||
.where(function() {
|
||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
||||
.orWhere('setting_key', 'like', 'analytics_%')
|
||||
.orWhere('setting_key', 'like', 'event_require_%');
|
||||
.orWhere('setting_key', 'like', 'event_require_%')
|
||||
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
@@ -74,7 +75,15 @@ router.get('/', async (req, res) => {
|
||||
// Event field requirements
|
||||
event_require_customer_name: settingsObject.event_require_customer_name !== false,
|
||||
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
||||
event_require_event_date: settingsObject.event_require_event_date !== false,
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||
// Upload settings (safe to expose - needed for client-side validation)
|
||||
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
|
||||
// SEO meta tag flags (safe to expose - these are intended for crawlers)
|
||||
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
|
||||
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
|
||||
seo_meta_noai: settingsObject.seo_meta_noai === true
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -350,34 +350,11 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
/**
|
||||
* Get security statistics for monitoring
|
||||
*/
|
||||
router.get('/security/stats', async (req, res) => {
|
||||
try {
|
||||
// Only allow admin access
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
}
|
||||
const admin = await db('admin_users').where({ id: decoded.id }).first();
|
||||
|
||||
if (!admin) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
// Get security statistics
|
||||
const stats = {
|
||||
|
||||
@@ -437,29 +437,63 @@ async function performLocalBackup(config, files) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRsyncCommand(config) {
|
||||
function validateRsyncParam(value, label) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${label}: contains disallowed characters`);
|
||||
}
|
||||
if (value.length > 1024) {
|
||||
throw new Error(`Invalid ${label}: too long`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildRsyncArgs(config) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
||||
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
||||
|
||||
if (!host || !remotePath) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
const options = ['-avz', '--delete', '--stats'];
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
throw new Error('Invalid rsync host format');
|
||||
}
|
||||
|
||||
const args = ['-avz', '--delete', '--stats'];
|
||||
if (config.backup_rsync_ssh_key) {
|
||||
options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`);
|
||||
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
|
||||
throw new Error('SSH key file not found or is not a file');
|
||||
}
|
||||
// Pass SSH options as separate array elements to avoid shell interpretation
|
||||
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
||||
}
|
||||
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`));
|
||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = config.backup_rsync_user
|
||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||
|
||||
const user = config.backup_rsync_user;
|
||||
if (user) {
|
||||
validateRsyncParam(user, 'user');
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
throw new Error('Invalid rsync username format');
|
||||
}
|
||||
}
|
||||
|
||||
const destination = user
|
||||
? `${user}@${host}:${remotePath}`
|
||||
: `${host}:${remotePath}`;
|
||||
|
||||
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
|
||||
args.push(source, destination);
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseRsyncStats(output) {
|
||||
@@ -479,9 +513,9 @@ function parseRsyncStats(output) {
|
||||
}
|
||||
|
||||
async function performRsyncBackup(config, files) {
|
||||
const command = buildRsyncCommand(config);
|
||||
const execAsync = getExecAsync();
|
||||
const { stdout } = await execAsync(command);
|
||||
const { spawnAsync } = require('../utils/safeExec');
|
||||
const rsyncArgs = buildRsyncArgs(config);
|
||||
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||
const stats = parseRsyncStats(stdout);
|
||||
|
||||
const backedUpFiles = files.map(file => file.relativePath);
|
||||
@@ -503,8 +537,7 @@ async function performRsyncBackup(config, files) {
|
||||
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
|
||||
backedUpSize: totalSize,
|
||||
backedUpFiles,
|
||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
|
||||
rsyncCommand: command
|
||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const crypto = require('crypto');
|
||||
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
@@ -163,10 +161,10 @@ class DatabaseBackupService {
|
||||
|
||||
try {
|
||||
// Use SQLite's backup API for consistency
|
||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
|
||||
|
||||
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
|
||||
|
||||
// Verify the backup
|
||||
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
|
||||
const verifyResult = await spawnAsync('sqlite3', [tempPath, 'PRAGMA integrity_check']);
|
||||
if (!verifyResult.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
@@ -192,14 +190,6 @@ class DatabaseBackupService {
|
||||
async createPostgreSQLBackup(outputPath, options = {}) {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
|
||||
// Build connection string with proper escaping
|
||||
const connectionParts = [
|
||||
`host=${host}`,
|
||||
`port=${port}`,
|
||||
`dbname=${database}`,
|
||||
`user=${user}`
|
||||
];
|
||||
|
||||
// Set PGPASSWORD environment variable for security
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
@@ -227,14 +217,17 @@ class DatabaseBackupService {
|
||||
pgDumpOptions.push('--compress=6');
|
||||
}
|
||||
|
||||
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
|
||||
|
||||
const pgDumpArgs = [
|
||||
...pgDumpOptions,
|
||||
'-h', host,
|
||||
'-p', String(port),
|
||||
'-U', user,
|
||||
'-d', database
|
||||
];
|
||||
|
||||
try {
|
||||
const { stderr } = await execAsync(command, {
|
||||
env,
|
||||
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
|
||||
});
|
||||
|
||||
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
|
||||
|
||||
// pg_dump writes progress to stderr, not an error
|
||||
if (stderr && !stderr.includes('dump complete')) {
|
||||
logger.warn('pg_dump warnings:', stderr);
|
||||
@@ -261,7 +254,7 @@ class DatabaseBackupService {
|
||||
try {
|
||||
if (this.dbType === 'sqlite') {
|
||||
// For SQLite, we can directly check integrity
|
||||
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
|
||||
const result = await spawnAsync('sqlite3', [backupPath, 'PRAGMA integrity_check']);
|
||||
if (!result.stdout.includes('ok')) {
|
||||
throw new Error('Backup integrity check failed');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
@@ -99,113 +100,74 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
// Fourth priority: Check if the email domain suggests a language
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
const domainLanguageMap = [
|
||||
{ domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
|
||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||
];
|
||||
for (const { domains, language: lang } of domainLanguageMap) {
|
||||
if (domains.some(d => domain.endsWith(d))) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Fall back to non-language-specific fields for backward compatibility
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
// Darken a hex color by a percentage (0-1)
|
||||
function darkenColor(hex, amount = 0.15) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
|
||||
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
|
||||
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
|
||||
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
// Get branding settings for logo and email colors
|
||||
let logoUrl = '';
|
||||
let companyName = 'PicPeak';
|
||||
let primaryColor = '#5C8762';
|
||||
let secondaryColor = '#f9f9f9';
|
||||
try {
|
||||
const brandingSettings = await db('app_settings')
|
||||
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_url', 'branding_company_name',
|
||||
'email_primary_color', 'email_secondary_color'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
|
||||
brandingSettings.forEach(setting => {
|
||||
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
|
||||
try {
|
||||
logoUrl = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
logoUrl = setting.setting_value;
|
||||
}
|
||||
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
|
||||
try {
|
||||
companyName = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
companyName = setting.setting_value;
|
||||
}
|
||||
const val = setting.setting_value;
|
||||
if (setting.setting_key === 'branding_logo_url' && val) {
|
||||
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
|
||||
} else if (setting.setting_key === 'branding_company_name' && val) {
|
||||
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
|
||||
} else if (setting.setting_key === 'email_primary_color' && val) {
|
||||
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
|
||||
} else if (setting.setting_key === 'email_secondary_color' && val) {
|
||||
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching branding settings:', error);
|
||||
}
|
||||
|
||||
// If no custom logo, use default PicPeak logo
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||
const hoverColor = darkenColor(primaryColor, 0.15);
|
||||
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
// Build full logo URL - ensure logoUrl is a valid non-empty string
|
||||
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
|
||||
const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png';
|
||||
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
|
||||
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="${language}">
|
||||
<head>
|
||||
@@ -233,7 +195,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -246,7 +208,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.email-content h2 {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
@@ -267,7 +229,7 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: #5C8762;
|
||||
background-color: ${primaryColor};
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
@@ -275,10 +237,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #4a6f4f;
|
||||
background-color: ${hoverColor};
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f9f9f9;
|
||||
background-color: ${secondaryColor};
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #eee;
|
||||
@@ -295,11 +257,11 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
margin: 5px 0;
|
||||
}
|
||||
a {
|
||||
color: #5C8762;
|
||||
color: ${primaryColor};
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #4a6f4f;
|
||||
color: ${hoverColor};
|
||||
}
|
||||
strong {
|
||||
color: #333;
|
||||
@@ -338,6 +300,168 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get translation from email_template_translations table with fallback chain
|
||||
let subject = '';
|
||||
let htmlBody = '';
|
||||
let textBody = '';
|
||||
|
||||
try {
|
||||
// Try requested language first, then English, then any available
|
||||
let translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (!translation) {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlBody = translation.body_html || '';
|
||||
textBody = translation.body_text || '';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
|
||||
}
|
||||
|
||||
// Fallback to legacy column-based fields if no translation found
|
||||
if (!subject && !htmlBody) {
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject_en || template.subject || '';
|
||||
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
|
||||
textBody = template[textField] || template.body_text_en || template.body_text || '';
|
||||
}
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
const passwordSecurityI18n = {
|
||||
en: '(Not shown for security reasons)',
|
||||
de: '(Aus Sicherheitsgründen nicht angezeigt)',
|
||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||
pt: '(Não exibido por motivos de segurança)',
|
||||
ru: '(Не показано в целях безопасности)',
|
||||
};
|
||||
const noPasswordI18n = {
|
||||
en: 'No password required',
|
||||
de: 'Kein Passwort erforderlich',
|
||||
nl: 'Geen wachtwoord vereist',
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
if (processedVariables.expires_at) {
|
||||
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Safe template replacement (no code execution, only simple variable substitution)
|
||||
function safeTemplateReplace(template, variables) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
variables.hasOwnProperty(key) ? String(variables[key]) : match
|
||||
);
|
||||
}
|
||||
|
||||
subject = safeTemplateReplace(subject, processedVariables);
|
||||
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
|
||||
textBody = safeTemplateReplace(textBody, processedVariables);
|
||||
|
||||
// Inject client access section if client_link is provided (#172)
|
||||
if (processedVariables.client_link) {
|
||||
const clientAccessI18n = {
|
||||
de: {
|
||||
label: 'Kundenzugang (Privat)',
|
||||
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
|
||||
link: 'Kundenzugang öffnen',
|
||||
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
|
||||
},
|
||||
ru: {
|
||||
label: 'Доступ клиента (Личный)',
|
||||
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
},
|
||||
nl: {
|
||||
label: 'Klanttoegang (Privé)',
|
||||
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
|
||||
link: 'Klanttoegang openen',
|
||||
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
link: 'Abrir Acesso do Cliente',
|
||||
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
|
||||
},
|
||||
en: {
|
||||
label: 'Client Access (Private)',
|
||||
desc: 'Review and manage photo visibility before sharing with guests:',
|
||||
link: 'Open Client Access',
|
||||
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
|
||||
},
|
||||
};
|
||||
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
|
||||
const clientAccessLabel = ci18n.label;
|
||||
const clientAccessDesc = ci18n.desc;
|
||||
const clientAccessLink = ci18n.link;
|
||||
const clientAccessWarning = ci18n.warning;
|
||||
const pinLabel = 'PIN';
|
||||
|
||||
htmlBody += `
|
||||
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="font-size: 15px;">🔒 ${clientAccessLabel}</strong>
|
||||
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
|
||||
<p style="margin: 8px 0;">
|
||||
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
|
||||
</p>
|
||||
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${clientAccessWarning}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
|
||||
|
||||
return { subject, htmlBody: styledHtmlBody, textBody };
|
||||
}
|
||||
@@ -558,5 +682,6 @@ module.exports = {
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
testEmailConnection,
|
||||
wrapEmailHtml
|
||||
};
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Environment Detection Service
|
||||
* Detects the deployment environment and generates update instructions accordingly.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Detect the current deployment environment
|
||||
* @returns {Object} Environment information
|
||||
*/
|
||||
async function detectEnvironment() {
|
||||
// Check for Docker environment
|
||||
const isDocker = fs.existsSync('/.dockerenv') ||
|
||||
process.env.DOCKER_CONTAINER === 'true';
|
||||
|
||||
// Determine project root (services -> src -> backend)
|
||||
const projectRoot = path.join(__dirname, '../../..');
|
||||
|
||||
// Check for git repository
|
||||
const isGit = fs.existsSync(path.join(projectRoot, '.git'));
|
||||
|
||||
// Check for docker-compose files
|
||||
const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) ||
|
||||
fs.existsSync(path.join(projectRoot, 'docker-compose.yaml'));
|
||||
|
||||
// Get app version
|
||||
let appVersion = '0.0.0';
|
||||
try {
|
||||
const packagePath = path.join(__dirname, '../../package.json');
|
||||
const packageContent = fs.readFileSync(packagePath, 'utf8');
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
appVersion = packageJson.version || '0.0.0';
|
||||
} catch (err) {
|
||||
logger.warn('Could not read package.json for version:', err.message);
|
||||
}
|
||||
|
||||
// Determine environment type
|
||||
let type;
|
||||
if (isDocker) {
|
||||
type = 'docker';
|
||||
} else if (isGit) {
|
||||
type = 'git';
|
||||
} else {
|
||||
type = 'standalone';
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
isDocker,
|
||||
isGit,
|
||||
hasDockerCompose,
|
||||
platform: process.platform,
|
||||
nodeVersion: process.version,
|
||||
appVersion
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate environment-specific update instructions
|
||||
* @param {Object} env - Environment info from detectEnvironment()
|
||||
* @param {string} targetVersion - Target version to update to
|
||||
* @returns {Object} Update instructions with pre-checks, steps, and post-checks
|
||||
*/
|
||||
function generateUpdateInstructions(env, targetVersion) {
|
||||
const instructions = {
|
||||
preChecks: [
|
||||
{
|
||||
id: 'backup',
|
||||
text: 'I have backed up my database',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: 'no-uploads',
|
||||
text: 'No uploads are currently in progress',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: 'downtime-aware',
|
||||
text: 'I understand the application will restart during update',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
steps: [],
|
||||
postChecks: [
|
||||
'Verify the application starts correctly',
|
||||
'Check the version in Admin -> System',
|
||||
'Review release notes for any breaking changes or required actions'
|
||||
],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
if (env.isDocker) {
|
||||
instructions.environmentName = 'Docker';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Pull latest images',
|
||||
command: 'docker compose pull',
|
||||
note: 'Downloads the new version images'
|
||||
},
|
||||
{
|
||||
description: 'Recreate containers with new images',
|
||||
command: 'docker compose up -d',
|
||||
note: 'Restarts containers with new version'
|
||||
},
|
||||
{
|
||||
description: 'Watch logs for startup (optional)',
|
||||
command: 'docker compose logs -f backend',
|
||||
note: 'Press Ctrl+C to exit logs',
|
||||
optional: true
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
|
||||
} else if (env.isGit) {
|
||||
instructions.environmentName = 'Git (Development)';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Fetch latest changes',
|
||||
command: 'git fetch origin',
|
||||
note: 'Downloads references from remote'
|
||||
},
|
||||
{
|
||||
description: 'Switch to new version tag',
|
||||
command: `git checkout v${targetVersion}`,
|
||||
note: 'Switches to the release version'
|
||||
},
|
||||
{
|
||||
description: 'Install backend dependencies',
|
||||
command: 'cd backend && npm install',
|
||||
note: 'Updates npm packages'
|
||||
},
|
||||
{
|
||||
description: 'Build frontend',
|
||||
command: 'cd frontend && npm install && npm run build',
|
||||
note: 'Compiles the frontend application'
|
||||
},
|
||||
{
|
||||
description: 'Run database migrations',
|
||||
command: 'cd backend && npm run migrate',
|
||||
note: 'Updates database schema'
|
||||
},
|
||||
{
|
||||
description: 'Restart application',
|
||||
command: '# Restart your application (pm2, systemd, etc.)',
|
||||
note: 'Method depends on your setup - e.g., pm2 restart picpeak'
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)');
|
||||
} else {
|
||||
instructions.environmentName = 'Standalone';
|
||||
instructions.steps = [
|
||||
{
|
||||
description: 'Download release archive',
|
||||
command: `# Download v${targetVersion} from GitHub Releases`,
|
||||
note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}`
|
||||
},
|
||||
{
|
||||
description: 'Backup current installation',
|
||||
command: '# Create backup of current files',
|
||||
note: 'Keep a copy of your current installation'
|
||||
},
|
||||
{
|
||||
description: 'Extract and replace application files',
|
||||
command: '# Extract release archive to installation directory',
|
||||
note: 'Preserve your .env file and storage directory'
|
||||
},
|
||||
{
|
||||
description: 'Install dependencies',
|
||||
command: 'cd backend && npm install --production',
|
||||
note: 'Updates npm packages'
|
||||
},
|
||||
{
|
||||
description: 'Run database migrations',
|
||||
command: 'cd backend && npm run migrate',
|
||||
note: 'Updates database schema'
|
||||
},
|
||||
{
|
||||
description: 'Restart application',
|
||||
command: '# Restart your application service',
|
||||
note: 'Method depends on your setup'
|
||||
}
|
||||
];
|
||||
instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating');
|
||||
instructions.warnings.push('Consider creating a full backup before updating');
|
||||
}
|
||||
|
||||
return instructions;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectEnvironment,
|
||||
generateUpdateInstructions
|
||||
};
|
||||
@@ -14,8 +14,9 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('./eventTypeService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Cache for schema detection
|
||||
let customerColumnCache = null;
|
||||
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
|
||||
|
||||
/**
|
||||
* Generate a unique slug for an event
|
||||
* @param {string} eventType
|
||||
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
|
||||
* @param {string} eventName
|
||||
* @param {string} eventDate
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
||||
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
|
||||
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
|
||||
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
|
||||
|
||||
const processedName = eventName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
@@ -127,7 +138,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests,
|
||||
// Upload settings
|
||||
allow_user_uploads,
|
||||
upload_category_id
|
||||
upload_category_id,
|
||||
// Photo cap
|
||||
photo_cap
|
||||
} = eventData;
|
||||
|
||||
const requirePassword = parseBooleanInput(require_password, true);
|
||||
@@ -196,7 +209,9 @@ const createEvent = async (eventData) => {
|
||||
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
|
||||
// Upload settings
|
||||
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
|
||||
upload_category_id: upload_category_id || null
|
||||
upload_category_id: upload_category_id || null,
|
||||
// Photo cap
|
||||
photo_cap: photo_cap || null
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Event Type Service Layer
|
||||
* Handles all event type-related business logic
|
||||
*
|
||||
* @module services/eventTypeService
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
/**
|
||||
* Get all event types
|
||||
* @param {Object} options - Filter options
|
||||
* @param {boolean} options.activeOnly - Only return active types
|
||||
* @returns {Promise<Array>} - Array of event types
|
||||
*/
|
||||
const getAllEventTypes = async (options = {}) => {
|
||||
const { activeOnly = false } = options;
|
||||
|
||||
let query = db('event_types').select('*');
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
}
|
||||
|
||||
const types = await query.orderBy('display_order', 'asc');
|
||||
return types;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active event types for dropdown/selection
|
||||
* @returns {Promise<Array>} - Array of active event types
|
||||
*/
|
||||
const getActiveEventTypes = async () => {
|
||||
return getAllEventTypes({ activeOnly: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by ID
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeById = async (id) => {
|
||||
const eventType = await db('event_types').where('id', id).first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by slug prefix
|
||||
* @param {string} slugPrefix - The slug prefix
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||
const eventType = await db('event_types')
|
||||
.where('slug_prefix', slugPrefix.toLowerCase())
|
||||
.first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
|
||||
* @param {string} slugPrefix - The slug prefix to validate
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const isValidEventType = async (slugPrefix) => {
|
||||
const normalized = slugPrefix.toLowerCase();
|
||||
|
||||
// Check in database
|
||||
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||
if (eventType && eventType.is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||
return legacyTypes.includes(normalized);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all valid slug prefixes (for validation)
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
const getValidSlugPrefixes = async () => {
|
||||
const types = await db('event_types')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.select('slug_prefix');
|
||||
|
||||
return types.map(t => t.slug_prefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new event type
|
||||
* @param {Object} eventTypeData - Event type data
|
||||
* @returns {Promise<Object>} - Created event type
|
||||
*/
|
||||
const createEventType = async (eventTypeData) => {
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = eventTypeData;
|
||||
|
||||
// Normalize slug_prefix
|
||||
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate slug_prefix
|
||||
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get max display order if not provided
|
||||
let finalDisplayOrder = display_order;
|
||||
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
|
||||
const maxOrder = await db('event_types').max('display_order as max').first();
|
||||
finalDisplayOrder = (maxOrder?.max || 0) + 1;
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
name,
|
||||
slug_prefix: normalizedSlugPrefix,
|
||||
emoji: emoji || '📷',
|
||||
theme_preset: theme_preset || 'default',
|
||||
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
||||
display_order: finalDisplayOrder,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const insertResult = await db('event_types').insert(insertData).returning('id');
|
||||
const eventTypeId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
return getEventTypeById(eventTypeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Object>} - Updated event type
|
||||
*/
|
||||
const updateEventType = async (id, updates) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData = {};
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name;
|
||||
}
|
||||
|
||||
if (updates.slug_prefix !== undefined) {
|
||||
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate (excluding current)
|
||||
const existing = await db('event_types')
|
||||
.where('slug_prefix', normalizedSlugPrefix)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateData.slug_prefix = normalizedSlugPrefix;
|
||||
}
|
||||
|
||||
if (updates.emoji !== undefined) {
|
||||
updateData.emoji = updates.emoji;
|
||||
}
|
||||
|
||||
if (updates.theme_preset !== undefined) {
|
||||
updateData.theme_preset = updates.theme_preset;
|
||||
}
|
||||
|
||||
if (updates.theme_config !== undefined) {
|
||||
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
||||
}
|
||||
|
||||
if (updates.display_order !== undefined) {
|
||||
updateData.display_order = updates.display_order;
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
updateData.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
await db('event_types').where('id', id).update(updateData);
|
||||
|
||||
return getEventTypeById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const deleteEventType = async (id) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types
|
||||
if (eventType.is_system) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
const eventsUsingType = await db('events')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db('event_types').where('id', id).del();
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder event types
|
||||
* @param {Array} orderedIds - Array of IDs in new order
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
const reorderEventTypes = async (orderedIds) => {
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await trx('event_types')
|
||||
.where('id', orderedIds[i])
|
||||
.update({ display_order: i + 1, updated_at: new Date() });
|
||||
}
|
||||
});
|
||||
|
||||
return getAllEventTypes();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get event type info for slug generation
|
||||
* Returns the slug_prefix to use for a given event type identifier
|
||||
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
|
||||
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
|
||||
*/
|
||||
const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||
// Try to find by slug_prefix first
|
||||
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
|
||||
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
// Try by ID if numeric
|
||||
if (!isNaN(eventTypeIdentifier)) {
|
||||
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for legacy types - return a compatible object
|
||||
const legacyDefaults = {
|
||||
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
|
||||
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
|
||||
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
|
||||
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
|
||||
};
|
||||
|
||||
const normalized = eventTypeIdentifier.toLowerCase();
|
||||
if (legacyDefaults[normalized]) {
|
||||
return legacyDefaults[normalized];
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllEventTypes,
|
||||
getActiveEventTypes,
|
||||
getEventTypeById,
|
||||
getEventTypeBySlugPrefix,
|
||||
isValidEventType,
|
||||
getValidSlugPrefixes,
|
||||
createEventType,
|
||||
updateEventType,
|
||||
deleteEventType,
|
||||
reorderEventTypes,
|
||||
getEventTypeForSlug
|
||||
};
|
||||
@@ -21,9 +21,11 @@ async function checkExpirations() {
|
||||
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
|
||||
|
||||
// Check for events needing warning emails
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
@@ -40,9 +42,11 @@ async function checkExpirations() {
|
||||
}
|
||||
|
||||
// Check for expired events
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
|
||||
@@ -23,10 +23,15 @@ class FeedbackService {
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true
|
||||
show_feedback_to_guests: true,
|
||||
identity_mode: 'simple'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Back-compat: rows created before migration 078 have NULL identity_mode.
|
||||
if (!settings.identity_mode) {
|
||||
settings.identity_mode = 'simple';
|
||||
}
|
||||
return settings;
|
||||
} catch (error) {
|
||||
logger.error('Error getting feedback settings:', error);
|
||||
@@ -73,23 +78,29 @@ class FeedbackService {
|
||||
*/
|
||||
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
|
||||
try {
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
|
||||
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
|
||||
|
||||
// Validate feedback type
|
||||
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
|
||||
throw new Error('Invalid feedback type');
|
||||
}
|
||||
|
||||
// Check if similar feedback already exists (prevent duplicates)
|
||||
// Check if similar feedback already exists (prevent duplicates).
|
||||
// When a per-person guest_id is present, scope the check to that guest
|
||||
// so two guests on the same device can independently like a photo.
|
||||
if (feedback_type !== 'comment') {
|
||||
const existing = await db('photo_feedback')
|
||||
const duplicateQuery = db('photo_feedback')
|
||||
.where({
|
||||
photo_id: photoId,
|
||||
event_id: eventId,
|
||||
feedback_type,
|
||||
guest_identifier: guestIdentifier
|
||||
})
|
||||
.first();
|
||||
});
|
||||
if (guest_id) {
|
||||
duplicateQuery.where('guest_id', guest_id);
|
||||
} else {
|
||||
duplicateQuery.where('guest_identifier', guestIdentifier);
|
||||
}
|
||||
const existing = await duplicateQuery.first();
|
||||
|
||||
if (existing) {
|
||||
if (feedback_type === 'rating' && rating !== existing.rating) {
|
||||
@@ -129,6 +140,7 @@ class FeedbackService {
|
||||
guest_name,
|
||||
guest_email,
|
||||
guest_identifier: guestIdentifier,
|
||||
guest_id: guest_id || null,
|
||||
ip_address,
|
||||
user_agent,
|
||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||
@@ -232,7 +244,7 @@ class FeedbackService {
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
|
||||
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
|
||||
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
|
||||
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
|
||||
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -460,6 +472,75 @@ class FeedbackService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize feedback belonging to a guest — sets guest_id to NULL on all
|
||||
* their feedback rows and clears guest_name/guest_email for privacy, then
|
||||
* recomputes denormalized photo counts on affected photos.
|
||||
*
|
||||
* Used by self-service "forget me" and admin guest deletion.
|
||||
*/
|
||||
async anonymizeGuestFeedback(guestId) {
|
||||
try {
|
||||
const affected = await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.where('guest_id', guestId)
|
||||
.update({
|
||||
guest_id: null,
|
||||
guest_name: null,
|
||||
guest_email: null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { anonymized: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error anonymizing guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin
|
||||
* guest merge and email-based identity recovery when a user re-registers.
|
||||
* Recomputes denormalized counts on affected photos.
|
||||
*/
|
||||
async mergeGuestFeedback(keepGuestId, sourceGuestIds) {
|
||||
try {
|
||||
const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId);
|
||||
if (sources.length === 0) {
|
||||
return { merged: 0, photos: 0 };
|
||||
}
|
||||
|
||||
const affected = await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.select('photo_id');
|
||||
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
|
||||
|
||||
await db('photo_feedback')
|
||||
.whereIn('guest_id', sources)
|
||||
.update({
|
||||
guest_id: keepGuestId,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
for (const pid of photoIds) {
|
||||
await this.updatePhotoFeedbackStats(pid);
|
||||
}
|
||||
|
||||
return { merged: affected.length, photos: photoIds.length };
|
||||
} catch (error) {
|
||||
logger.error('Error merging guest feedback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FeedbackService();
|
||||
@@ -5,7 +5,7 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const { isVideoMimeType } = require('./videoProcessor');
|
||||
const mime = require('mime-types');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Guest identity recovery service (Phase 3.2).
|
||||
*
|
||||
* Sends a short-lived 6-digit verification code to a guest's email address
|
||||
* so they can re-link their identity across devices. The code is stored as
|
||||
* a bcrypt hash in `guest_verification_codes` with a 15-minute expiry.
|
||||
*
|
||||
* Uses the email transporter from emailProcessor — no new template row is
|
||||
* needed; the email body is built inline so this works out of the box.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
function generateCode() {
|
||||
// 6 digits, zero-padded.
|
||||
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
async function createCode(eventId, email) {
|
||||
const code = generateCode();
|
||||
const codeHash = await bcrypt.hash(code, 10);
|
||||
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
||||
|
||||
// Invalidate any previous unconsumed codes for this email+event.
|
||||
await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
await db('guest_verification_codes').insert({
|
||||
event_id: eventId,
|
||||
email: email.toLowerCase(),
|
||||
code_hash: codeHash,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
const transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
const config = await db('email_configs').first();
|
||||
if (!config) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
const subject = `Your verification code: ${code}`;
|
||||
const htmlBody = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px;">
|
||||
<h2>Welcome back to ${eventName}</h2>
|
||||
<p>Enter this code to recover your picks in the gallery:</p>
|
||||
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px; margin: 20px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
This code expires in 15 minutes. If you did not request it, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
to: toEmail,
|
||||
subject,
|
||||
html: styledHtml,
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`,
|
||||
});
|
||||
|
||||
logger.info('Guest recovery code sent', { email: toEmail });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a code. Returns true if valid + marks it consumed.
|
||||
* Increments attempts on failure. Rejects after MAX_ATTEMPTS.
|
||||
*/
|
||||
async function verifyCode(eventId, email, submittedCode) {
|
||||
const normalized = String(submittedCode || '').trim();
|
||||
if (!/^\d{6}$/.test(normalized)) {
|
||||
return { ok: false, reason: 'invalid_format' };
|
||||
}
|
||||
|
||||
const row = await db('guest_verification_codes')
|
||||
.where({ event_id: eventId, email: email.toLowerCase() })
|
||||
.whereNull('consumed_at')
|
||||
.andWhere('expires_at', '>', new Date())
|
||||
.orderBy('created_at', 'desc')
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'expired_or_missing' };
|
||||
}
|
||||
|
||||
if (row.attempts >= MAX_ATTEMPTS) {
|
||||
await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() });
|
||||
return { ok: false, reason: 'too_many_attempts' };
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(normalized, row.code_hash);
|
||||
if (!matches) {
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ attempts: row.attempts + 1 });
|
||||
return { ok: false, reason: 'wrong_code' };
|
||||
}
|
||||
|
||||
await db('guest_verification_codes')
|
||||
.where('id', row.id)
|
||||
.update({ consumed_at: db.fn.now() });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCode,
|
||||
sendRecoveryEmail,
|
||||
verifyCode,
|
||||
CODE_TTL_MS,
|
||||
MAX_ATTEMPTS,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const sharp = require('sharp');
|
||||
const exifr = require('exifr');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
@@ -119,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
failOnError: false // Don't fail on minor issues
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
// Apply resize with configured settings
|
||||
// For square thumbnails with 'cover' fit, we crop to center
|
||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||
@@ -287,4 +291,220 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
|
||||
// Hero image settings - optimized for large displays
|
||||
const DEFAULT_HERO_WIDTH = 1920;
|
||||
const DEFAULT_HERO_HEIGHT = 1080;
|
||||
const DEFAULT_HERO_QUALITY = 85;
|
||||
const DEFAULT_HERO_FORMAT = 'jpeg';
|
||||
|
||||
const getHeroPath = () => path.join(getStoragePath(), 'heroes');
|
||||
|
||||
/**
|
||||
* Generate a hero-optimized image for gallery headers
|
||||
* Outputs a 1920x1080 image suitable for full-width hero sections
|
||||
*/
|
||||
async function generateHeroImage(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const heroFilename = `hero_${filename}`;
|
||||
const heroDir = getHeroPath();
|
||||
const heroPath = path.join(heroDir, heroFilename);
|
||||
|
||||
// Ensure hero directory exists
|
||||
await fs.mkdir(heroDir, { recursive: true });
|
||||
|
||||
// Check if we need to regenerate
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(heroPath);
|
||||
logger.info(`Deleted existing hero image: ${heroPath}`);
|
||||
} catch (err) {
|
||||
// File might not exist, that's okay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// First, verify the source image is complete and valid
|
||||
const metadata = await sharp(imagePath).metadata();
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw new Error('Invalid image metadata - file may be incomplete');
|
||||
}
|
||||
|
||||
// Calculate dimensions to maintain aspect ratio while fitting within hero bounds
|
||||
const heroWidth = options.width || DEFAULT_HERO_WIDTH;
|
||||
const heroHeight = options.height || DEFAULT_HERO_HEIGHT;
|
||||
const quality = options.quality || DEFAULT_HERO_QUALITY;
|
||||
|
||||
// Create sharp instance with memory-efficient settings
|
||||
let sharpInstance = sharp(imagePath, {
|
||||
limitInputPixels: 268402689,
|
||||
sequentialRead: true,
|
||||
failOnError: false
|
||||
});
|
||||
|
||||
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
// Resize to fit hero dimensions while maintaining aspect ratio
|
||||
// Use 'cover' to fill the hero area (crops if needed)
|
||||
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
||||
withoutEnlargement: false, // Allow upscaling for small images
|
||||
fit: 'cover',
|
||||
position: 'center'
|
||||
});
|
||||
|
||||
// Apply JPEG format with high quality
|
||||
sharpInstance = sharpInstance.jpeg({
|
||||
quality: quality,
|
||||
progressive: true,
|
||||
mozjpeg: true
|
||||
});
|
||||
|
||||
// Save the hero image
|
||||
await sharpInstance.toFile(heroPath);
|
||||
|
||||
// Verify the hero image was created successfully
|
||||
const stats = await fs.stat(heroPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Generated hero image is empty');
|
||||
}
|
||||
|
||||
logger.info(`Generated hero image for ${filename}: ${heroPath}`);
|
||||
return path.relative(getStoragePath(), heroPath);
|
||||
} catch (error) {
|
||||
const msg = (error && error.message) ? error.message : String(error);
|
||||
logger.error(`Failed to generate hero image for ${filename}: ${msg}`);
|
||||
|
||||
// Clean up any partially created file
|
||||
try {
|
||||
await fs.unlink(heroPath);
|
||||
} catch (unlinkErr) {
|
||||
// Ignore unlink errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hero image exists and is valid
|
||||
*/
|
||||
async function isHeroValid(heroPath) {
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), heroPath);
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
||||
if (stats.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to read metadata to ensure it's a valid image
|
||||
await sharp(fullPath).metadata();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a hero image exists for a photo, regenerate if needed
|
||||
*/
|
||||
async function ensureHeroImage(photo) {
|
||||
const { db } = require('../database/db');
|
||||
const { resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
let originalPath;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
originalPath = resolvePhotoFilePath(event, photo);
|
||||
logger.info(`Ensuring hero image for photo ${photo.id} from source: ${originalPath}`);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original path for hero image (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if hero image exists and is valid
|
||||
if (photo.hero_path) {
|
||||
const isValid = await isHeroValid(photo.hero_path);
|
||||
if (isValid) {
|
||||
return photo.hero_path;
|
||||
}
|
||||
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
// Generate new hero image
|
||||
const newHeroPath = await generateHeroImage(originalPath, { regenerate: true });
|
||||
|
||||
if (newHeroPath) {
|
||||
// Update database with new hero path
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({ hero_path: newHeroPath });
|
||||
|
||||
logger.info(`Regenerated hero image for photo ${photo.id}`);
|
||||
return newHeroPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract capture date from EXIF metadata
|
||||
* @param {string} imagePath - Path to the image file
|
||||
* @returns {Date|null} - The capture date or null if not available
|
||||
*/
|
||||
async function extractCaptureDate(imagePath) {
|
||||
try {
|
||||
// Parse EXIF data, looking for common date fields
|
||||
const exif = await exifr.parse(imagePath, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate']
|
||||
});
|
||||
|
||||
if (!exif) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate
|
||||
const captureDate = exif.DateTimeOriginal ||
|
||||
exif.CreateDate ||
|
||||
exif.DateTimeDigitized ||
|
||||
exif.ModifyDate;
|
||||
|
||||
if (captureDate) {
|
||||
// exifr returns Date objects directly when parsing dates
|
||||
if (captureDate instanceof Date) {
|
||||
// Validate the date is reasonable (not in the future, not before 1990)
|
||||
const now = new Date();
|
||||
const minDate = new Date('1990-01-01');
|
||||
if (captureDate > minDate && captureDate <= now) {
|
||||
return captureDate;
|
||||
}
|
||||
}
|
||||
// Handle string dates if necessary
|
||||
if (typeof captureDate === 'string') {
|
||||
const parsed = new Date(captureDate);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Log only as debug - many images don't have EXIF data
|
||||
logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateThumbnail,
|
||||
isThumbnailValid,
|
||||
ensureThumbnail,
|
||||
generateVideoPlaceholder,
|
||||
generateHeroImage,
|
||||
isHeroValid,
|
||||
ensureHeroImage,
|
||||
extractCaptureDate
|
||||
};
|
||||
|
||||
@@ -23,13 +23,13 @@ class PhotoExportService {
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -37,9 +37,9 @@ class PhotoExportService {
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.file_size',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.size_bytes',
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
)
|
||||
.orderBy('photos.filename', 'asc');
|
||||
|
||||
@@ -86,7 +86,7 @@ class PhotoExportService {
|
||||
const { filename_format = 'original', separator = 'newline' } = options;
|
||||
|
||||
const filenames = photos.map(photo =>
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
|
||||
);
|
||||
|
||||
let content;
|
||||
@@ -126,14 +126,14 @@ class PhotoExportService {
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
'file_size',
|
||||
'created_at'
|
||||
'size_bytes',
|
||||
'uploaded_at'
|
||||
];
|
||||
|
||||
const rows = photos.map(photo => [
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename,
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
|
||||
photo.original_filename || '',
|
||||
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
|
||||
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
|
||||
photo.feedback_count || 0,
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
@@ -141,8 +141,8 @@ class PhotoExportService {
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
photo.file_size || '',
|
||||
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
||||
photo.size_bytes || '',
|
||||
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
@@ -170,7 +170,7 @@ class PhotoExportService {
|
||||
|
||||
for (const photo of photos) {
|
||||
const baseFilename = filename_format === 'original'
|
||||
? photo.original_filename
|
||||
? (photo.original_filename || photo.filename)
|
||||
: photo.filename;
|
||||
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
||||
@@ -209,21 +209,21 @@ class PhotoExportService {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename,
|
||||
original_filename: photo.original_filename || null,
|
||||
category: photo.category_name || null,
|
||||
rating: {
|
||||
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
|
||||
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
|
||||
count: photo.feedback_count || 0
|
||||
},
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_count || 0,
|
||||
comments: photo.comment_count || 0,
|
||||
dimensions: {
|
||||
width: photo.width,
|
||||
height: photo.height
|
||||
width: photo.width || null,
|
||||
height: photo.height || null
|
||||
},
|
||||
file_size: photo.file_size,
|
||||
created_at: photo.created_at
|
||||
size_bytes: photo.size_bytes || null,
|
||||
uploaded_at: photo.uploaded_at || null
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
let imageMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
// Process image: generate thumbnail and extract dimensions
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Extract image dimensions using sharp
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(newPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
imageMetadata = {
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
@@ -186,6 +201,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const photoData = {
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
@@ -205,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
// Add image dimensions if available
|
||||
if (!isVideo && imageMetadata) {
|
||||
photoData.width = imageMetadata.width;
|
||||
photoData.height = imageMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
|
||||
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Get photos for an event with optional filtering
|
||||
|
||||
@@ -163,22 +163,13 @@ async function createRateLimiter() {
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async (req) => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
@@ -223,22 +214,13 @@ async function createAuthRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: config.authMaxRequests,
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async () => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
|
||||
@@ -30,10 +30,10 @@ async function verifyRecaptcha(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no secret key configured, log warning but pass
|
||||
// If no secret key configured, fail closed
|
||||
if (!secretKey) {
|
||||
console.warn('reCAPTCHA enabled but no secret key configured');
|
||||
return true;
|
||||
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,9 +4,7 @@ const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -418,12 +416,14 @@ class RestoreService {
|
||||
let availableBytes = 0;
|
||||
let diskCheckSucceeded = false;
|
||||
try {
|
||||
const { exec } = require('child_process');
|
||||
const execAsync = promisify(exec);
|
||||
// Use root path as fallback if storage path doesn't exist yet
|
||||
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
||||
const parsed = parseInt(stdout.trim());
|
||||
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
|
||||
// Parse df output: last line, 4th column is available KB
|
||||
const lines = stdout.trim().split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const columns = lastLine.trim().split(/\s+/);
|
||||
const parsed = parseInt(columns[3]);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||
diskCheckSucceeded = true;
|
||||
@@ -492,15 +492,12 @@ class RestoreService {
|
||||
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
|
||||
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
|
||||
} else {
|
||||
// PostgreSQL backup
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
const env = { ...process.env, PGPASSWORD: password };
|
||||
await execAsync(
|
||||
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
|
||||
{ env }
|
||||
);
|
||||
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
|
||||
}
|
||||
|
||||
// Compress database backup
|
||||
@@ -513,8 +510,8 @@ class RestoreService {
|
||||
this.log('info', 'Backing up current files...');
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
|
||||
|
||||
await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`);
|
||||
|
||||
await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]);
|
||||
}
|
||||
|
||||
// Create backup manifest
|
||||
@@ -696,10 +693,10 @@ class RestoreService {
|
||||
|
||||
try {
|
||||
// Restore from backup
|
||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
|
||||
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
||||
|
||||
// Verify integrity
|
||||
const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`);
|
||||
const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']);
|
||||
if (!integrityCheck.stdout.includes('ok')) {
|
||||
throw new Error('Database integrity check failed after restore');
|
||||
}
|
||||
@@ -722,21 +719,12 @@ class RestoreService {
|
||||
// Drop and recreate database (extremely dangerous!)
|
||||
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
||||
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
|
||||
{ env }
|
||||
);
|
||||
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
|
||||
{ env }
|
||||
);
|
||||
|
||||
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
|
||||
|
||||
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
|
||||
|
||||
// Restore from backup
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
|
||||
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
|
||||
);
|
||||
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
|
||||
}
|
||||
|
||||
// Re-initialize database connection
|
||||
@@ -987,14 +975,11 @@ class RestoreService {
|
||||
|
||||
if (this.dbType === 'sqlite') {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
|
||||
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
||||
} else {
|
||||
const { host, port, user, password, database } = knexConfig.connection;
|
||||
const env = { ...process.env, PGPASSWORD: password };
|
||||
await execAsync(
|
||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
|
||||
{ env }
|
||||
);
|
||||
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
|
||||
}
|
||||
|
||||
await fs.unlink(decompressedPath);
|
||||
@@ -1004,7 +989,7 @@ class RestoreService {
|
||||
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
||||
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`);
|
||||
await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]);
|
||||
}
|
||||
|
||||
this.log('info', 'Rollback completed successfully');
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
let cachedRobotsTxt = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
||||
|
||||
function parseSetting(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSeoSettings() {
|
||||
const rows = await db('app_settings')
|
||||
.where('setting_type', 'seo')
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settings = {};
|
||||
for (const row of rows) {
|
||||
settings[row.setting_key] = parseSetting(row.setting_value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
const SOCIAL_BOTS = [
|
||||
'Twitterbot',
|
||||
'facebookexternalhit',
|
||||
'LinkedInBot',
|
||||
'Slackbot',
|
||||
'WhatsApp',
|
||||
'TelegramBot',
|
||||
'Discordbot'
|
||||
];
|
||||
|
||||
async function generateRobotsTxt() {
|
||||
const now = Date.now();
|
||||
if (cachedRobotsTxt && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
||||
return cachedRobotsTxt;
|
||||
}
|
||||
|
||||
const settings = await getSeoSettings();
|
||||
const allowIndexing = settings.seo_allow_indexing === true;
|
||||
const blockAiCrawlers = settings.seo_block_ai_crawlers !== false;
|
||||
const blockSocialBots = settings.seo_block_social_bots === true;
|
||||
const aiAgents = Array.isArray(settings.seo_blocked_ai_agents)
|
||||
? settings.seo_blocked_ai_agents
|
||||
: [];
|
||||
const customRules = Array.isArray(settings.seo_custom_rules)
|
||||
? settings.seo_custom_rules
|
||||
: [];
|
||||
const sitemapUrl = settings.seo_sitemap_url || '';
|
||||
|
||||
const lines = [];
|
||||
|
||||
// Always block admin and API paths for all agents
|
||||
lines.push('# Protected paths');
|
||||
lines.push('User-agent: *');
|
||||
lines.push('Disallow: /admin');
|
||||
lines.push('Disallow: /api');
|
||||
lines.push('');
|
||||
|
||||
if (!allowIndexing) {
|
||||
// Block everything for all agents
|
||||
lines.push('# Indexing disabled - block all crawlers');
|
||||
lines.push('User-agent: *');
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Block AI crawlers if enabled
|
||||
if (blockAiCrawlers && aiAgents.length > 0) {
|
||||
lines.push('# AI/LLM crawler blocking');
|
||||
for (const agent of aiAgents) {
|
||||
lines.push(`User-agent: ${agent}`);
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Block social bots if enabled
|
||||
if (blockSocialBots) {
|
||||
lines.push('# Social media bot blocking');
|
||||
for (const bot of SOCIAL_BOTS) {
|
||||
lines.push(`User-agent: ${bot}`);
|
||||
lines.push('Disallow: /');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Custom rules
|
||||
if (customRules.length > 0) {
|
||||
lines.push('# Custom rules');
|
||||
for (const rule of customRules) {
|
||||
if (rule.userAgent && Array.isArray(rule.disallow)) {
|
||||
lines.push(`User-agent: ${rule.userAgent}`);
|
||||
for (const path of rule.disallow) {
|
||||
lines.push(`Disallow: ${path}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sitemap
|
||||
if (sitemapUrl) {
|
||||
lines.push(`Sitemap: ${sitemapUrl}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const result = lines.join('\n');
|
||||
cachedRobotsTxt = result;
|
||||
cacheTimestamp = now;
|
||||
return result;
|
||||
}
|
||||
|
||||
function clearRobotsTxtCache() {
|
||||
cachedRobotsTxt = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRobotsTxt,
|
||||
clearRobotsTxtCache
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
|
||||
const SETTING_KEY = 'general_short_gallery_urls';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
@@ -87,7 +88,7 @@ const buildShareLinkVariants = async ({ slug, shareToken }) => {
|
||||
|
||||
const shortEnabled = await isShortGalleryUrlsEnabled();
|
||||
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const frontendBase = await getFrontendBaseUrl();
|
||||
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
|
||||
return {
|
||||
@@ -112,7 +113,8 @@ const getEventShareToken = (event) => {
|
||||
|
||||
const ACTIVE_EVENT_FILTER = {
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
is_archived: formatBoolean(false),
|
||||
is_draft: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier) => {
|
||||
|
||||
@@ -85,6 +85,16 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal S3 endpoints in production
|
||||
// Local endpoints (e.g. MinIO on localhost) are allowed in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const { validateExternalUrl } = require('../../utils/networkValidation');
|
||||
const urlCheck = validateExternalUrl(endpoint);
|
||||
if (!urlCheck.valid) {
|
||||
throw new Error(`Invalid S3 endpoint: ${urlCheck.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
s3Config.endpoint = endpoint;
|
||||
|
||||
// For S3-compatible services with custom endpoints, force path style
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Update Notification Service
|
||||
* Checks for updates and sends email notifications to administrators.
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { checkForUpdates } = require('./updateCheckService');
|
||||
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Get update notification settings from database
|
||||
*/
|
||||
async function getUpdateNotificationSettings() {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'update_email_notifications_enabled',
|
||||
'update_email_recipients',
|
||||
'last_notified_version'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const result = {};
|
||||
for (const setting of settings) {
|
||||
try {
|
||||
result[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
result[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: result.update_email_notifications_enabled === true,
|
||||
recipients: result.update_email_recipients || '',
|
||||
lastNotifiedVersion: result.last_notified_version || ''
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching update notification settings:', error);
|
||||
return {
|
||||
enabled: false,
|
||||
recipients: '',
|
||||
lastNotifiedVersion: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last notified version in database
|
||||
*/
|
||||
async function updateLastNotifiedVersion(version) {
|
||||
try {
|
||||
await db('app_settings')
|
||||
.where('setting_key', 'last_notified_version')
|
||||
.update({
|
||||
setting_value: JSON.stringify(version),
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating last notified version:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin email addresses to notify
|
||||
* If recipients setting is empty, get all active admin emails
|
||||
*/
|
||||
async function getNotificationRecipients(recipientsSetting) {
|
||||
try {
|
||||
if (recipientsSetting && recipientsSetting.trim()) {
|
||||
// Use configured recipients (comma-separated)
|
||||
return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Fallback: get all active admin user emails
|
||||
const admins = await db('admin_users')
|
||||
.where('is_active', true)
|
||||
.whereNotNull('email')
|
||||
.select('email');
|
||||
|
||||
return admins.map(admin => admin.email).filter(Boolean);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching notification recipients:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates and send notification emails if new version is available
|
||||
*/
|
||||
async function checkAndNotifyUpdates() {
|
||||
logger.info('Update notification service: Checking for updates...');
|
||||
|
||||
try {
|
||||
// Check if update notifications are enabled
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
|
||||
if (!settings.enabled) {
|
||||
logger.info('Update email notifications are disabled');
|
||||
return { notified: false, reason: 'notifications_disabled' };
|
||||
}
|
||||
|
||||
// Check for available updates
|
||||
const updateInfo = await checkForUpdates();
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
logger.info('No updates available');
|
||||
return { notified: false, reason: 'no_updates' };
|
||||
}
|
||||
|
||||
const newVersion = updateInfo.latest.forChannel;
|
||||
|
||||
// Check if we've already notified about this version
|
||||
if (settings.lastNotifiedVersion === newVersion) {
|
||||
logger.info(`Already notified about version ${newVersion}`);
|
||||
return { notified: false, reason: 'already_notified' };
|
||||
}
|
||||
|
||||
// Get recipients
|
||||
const recipients = await getNotificationRecipients(settings.recipients);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
logger.warn('No recipients configured for update notifications');
|
||||
return { notified: false, reason: 'no_recipients' };
|
||||
}
|
||||
|
||||
// Ensure email transporter is initialized
|
||||
await initializeTransporter();
|
||||
|
||||
// Send email to each recipient
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
|
||||
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const email of recipients) {
|
||||
try {
|
||||
await sendTemplateEmail(email, 'version_update_available', {
|
||||
current_version: updateInfo.current,
|
||||
new_version: newVersion,
|
||||
channel: channelLabel,
|
||||
release_notes_url: releaseNotesUrl,
|
||||
admin_url: `${frontendUrl}/admin`
|
||||
});
|
||||
successCount++;
|
||||
logger.info(`Update notification sent to ${email}`);
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`Failed to send update notification to ${email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last notified version
|
||||
if (successCount > 0) {
|
||||
await updateLastNotifiedVersion(newVersion);
|
||||
logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`);
|
||||
}
|
||||
|
||||
return {
|
||||
notified: successCount > 0,
|
||||
newVersion,
|
||||
successCount,
|
||||
errorCount,
|
||||
totalRecipients: recipients.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error in update notification service:', error);
|
||||
return { notified: false, reason: 'error', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force send update notification (for manual trigger from admin UI)
|
||||
*/
|
||||
async function sendUpdateNotificationNow() {
|
||||
logger.info('Manually triggering update notification...');
|
||||
|
||||
try {
|
||||
// Check for available updates
|
||||
const updateInfo = await checkForUpdates(true); // Force refresh
|
||||
|
||||
if (!updateInfo.updateAvailable) {
|
||||
return { success: false, message: 'No updates available' };
|
||||
}
|
||||
|
||||
const newVersion = updateInfo.latest.forChannel;
|
||||
const settings = await getUpdateNotificationSettings();
|
||||
|
||||
// Get recipients
|
||||
const recipients = await getNotificationRecipients(settings.recipients);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return { success: false, message: 'No recipients configured' };
|
||||
}
|
||||
|
||||
// Ensure email transporter is initialized
|
||||
await initializeTransporter();
|
||||
|
||||
// Send email to each recipient
|
||||
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
|
||||
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const email of recipients) {
|
||||
try {
|
||||
await sendTemplateEmail(email, 'version_update_available', {
|
||||
current_version: updateInfo.current,
|
||||
new_version: newVersion,
|
||||
channel: channelLabel,
|
||||
release_notes_url: releaseNotesUrl
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
logger.error(`Failed to send update notification to ${email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last notified version
|
||||
if (successCount > 0) {
|
||||
await updateLastNotifiedVersion(newVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount > 0,
|
||||
newVersion,
|
||||
successCount,
|
||||
errorCount,
|
||||
totalRecipients: recipients.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error sending manual update notification:', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAndNotifyUpdates,
|
||||
sendUpdateNotificationNow,
|
||||
getUpdateNotificationSettings,
|
||||
getNotificationRecipients
|
||||
};
|
||||
@@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000;
|
||||
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
// Map of file extension to MIME type(s)
|
||||
const EXTENSION_TO_MIME = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'mp4': 'video/mp4',
|
||||
'm4v': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
||||
|
||||
let cachedAllowedTypes = null;
|
||||
let allowedTypesCacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (setting) => {
|
||||
if (!setting || setting.setting_value == null) {
|
||||
return null;
|
||||
@@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => {
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a comma-separated list of file extensions into an array of MIME types.
|
||||
* Unknown extensions are silently ignored.
|
||||
*/
|
||||
const extensionsToMimeTypes = (extString) => {
|
||||
if (!extString || typeof extString !== 'string') {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
const mimeSet = new Set();
|
||||
extString.split(',').forEach(ext => {
|
||||
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
|
||||
const mime = EXTENSION_TO_MIME[cleaned];
|
||||
if (mime) {
|
||||
mimeSet.add(mime);
|
||||
}
|
||||
});
|
||||
|
||||
if (mimeSet.size === 0) {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
return Array.from(mimeSet);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the allowed MIME types for uploads from the database setting.
|
||||
* Returns an array of MIME type strings, e.g. ['image/jpeg', 'image/png', 'video/mp4'].
|
||||
*/
|
||||
const getAllowedMimeTypes = async () => {
|
||||
if (Date.now() < allowedTypesCacheExpiresAt && cachedAllowedTypes) {
|
||||
return cachedAllowedTypes;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where({ setting_key: 'general_allowed_file_types' })
|
||||
.first();
|
||||
|
||||
let rawValue = setting?.setting_value;
|
||||
if (typeof rawValue === 'string') {
|
||||
try { rawValue = JSON.parse(rawValue); } catch { /* keep string */ }
|
||||
}
|
||||
|
||||
const mimeTypes = extensionsToMimeTypes(rawValue);
|
||||
cachedAllowedTypes = mimeTypes;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return mimeTypes;
|
||||
} catch (error) {
|
||||
console.error('Failed to read allowed file types setting:', error.message);
|
||||
const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
cachedAllowedTypes = fallback;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllowedTypesCache = () => {
|
||||
allowedTypesCacheExpiresAt = 0;
|
||||
cachedAllowedTypes = null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMaxFilesPerUpload,
|
||||
clearMaxFilesPerUploadCache,
|
||||
getAllowedMimeTypes,
|
||||
clearAllowedTypesCache,
|
||||
extensionsToMimeTypes,
|
||||
EXTENSION_TO_MIME,
|
||||
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD,
|
||||
DEFAULT_ALLOWED_FILE_TYPES
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
|
||||
* @param {object} params - { email, roleId, invitedById }
|
||||
* @returns {Promise<object>} Created invitation details
|
||||
*/
|
||||
async function createInvitation({ email, roleId, invitedById }) {
|
||||
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
|
||||
// Check if email already exists
|
||||
const existingUser = await db('admin_users').where('email', email).first();
|
||||
if (existingUser) {
|
||||
@@ -42,6 +42,11 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
throw new NotFoundError('Role', roleId);
|
||||
}
|
||||
|
||||
// Role hierarchy: only super_admin can invite super_admin
|
||||
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
|
||||
throw new ValidationError('Only Super Admins can invite new Super Admins');
|
||||
}
|
||||
|
||||
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
@@ -60,7 +65,7 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
// Queue invitation email
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||
await queueEmail(null, email, 'admin_invitation', {
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
invite_link: `${frontendUrl}/invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
@@ -213,7 +218,7 @@ async function getAdminUserById(id) {
|
||||
* @param {number} updatedById - ID of user making the update
|
||||
* @returns {Promise<object>} Updated user
|
||||
*/
|
||||
async function updateAdminUser(id, updates, updatedById) {
|
||||
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
@@ -248,6 +253,34 @@ async function updateAdminUser(id, updates, updatedById) {
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', updates.role_id);
|
||||
}
|
||||
|
||||
// Role hierarchy enforcement
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
|
||||
|
||||
// Only super_admin can assign super_admin role
|
||||
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
|
||||
throw new ValidationError('Only Super Admins can assign the Super Admin role');
|
||||
}
|
||||
|
||||
// Prevent self-role-update
|
||||
if (id === updatedById) {
|
||||
throw new ValidationError('Cannot change your own role');
|
||||
}
|
||||
|
||||
// Prevent downgrading the last super_admin
|
||||
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
|
||||
const superAdminCount = await db('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (Number(superAdminCount?.count) <= 1) {
|
||||
throw new ValidationError('Cannot demote the last Super Admin');
|
||||
}
|
||||
}
|
||||
|
||||
allowedUpdates.role_id = updates.role_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* WatermarkGeneratorService
|
||||
*
|
||||
* Handles batch generation of pre-watermarked images for fast serving.
|
||||
* This service is responsible for:
|
||||
* - Generating watermarks for newly uploaded photos
|
||||
* - Regenerating all watermarks when settings change
|
||||
* - Tracking regeneration progress
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkGeneratorService {
|
||||
constructor() {
|
||||
// Track active regeneration jobs
|
||||
this.activeJobs = new Map();
|
||||
// Batch size for processing (to manage memory)
|
||||
this.batchSize = 10;
|
||||
// Concurrent processing limit
|
||||
this.concurrentLimit = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermark for a single photo
|
||||
* @param {number} photoId - The photo ID
|
||||
* @returns {Object} Result with success status and watermark path
|
||||
*/
|
||||
async generateForPhoto(photoId) {
|
||||
try {
|
||||
// Get photo with event info
|
||||
const photo = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.id', photoId)
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return { success: false, error: 'Photo not found' };
|
||||
}
|
||||
|
||||
// Skip video files
|
||||
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
|
||||
return { success: false, error: 'Videos do not support watermarks' };
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Resolve the original file path
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, error: 'Could not resolve photo path' };
|
||||
}
|
||||
|
||||
// Generate and save watermark
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
// Update database with watermark path
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full file path for a photo
|
||||
*/
|
||||
resolvePhotoPath(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
// Handle external/reference mode
|
||||
if (photo.source_mode === 'reference' && photo.external_relpath) {
|
||||
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
|
||||
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
|
||||
}
|
||||
|
||||
// Standard managed mode
|
||||
if (photo.file_path) {
|
||||
// file_path might be absolute or relative
|
||||
if (path.isAbsolute(photo.file_path)) {
|
||||
return photo.file_path;
|
||||
}
|
||||
return path.join(storagePath, photo.file_path);
|
||||
}
|
||||
|
||||
// Fallback to constructing path from slug and filename
|
||||
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarks for all photos in an event
|
||||
* @param {number} eventId - The event ID
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async generateForEvent(eventId, onProgress = null) {
|
||||
const results = { total: 0, success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
// Get all photos for the event (excluding videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get watermark settings once
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { ...results, errors: ['Watermarking is disabled'] };
|
||||
}
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermarks for event ${eventId}:`, error);
|
||||
return { ...results, errors: [...results.errors, error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process watermark for a single photo (internal helper)
|
||||
*/
|
||||
async processPhotoWatermark(photo, settings) {
|
||||
try {
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
|
||||
}
|
||||
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return { ...result, photoId: photo.id };
|
||||
} catch (error) {
|
||||
return { success: false, photoId: photo.id, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate watermarks for all photos in the system
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async regenerateAll(onProgress = null) {
|
||||
const jobId = Date.now().toString();
|
||||
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
|
||||
|
||||
try {
|
||||
this.activeJobs.set(jobId, results);
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
results.status = 'completed';
|
||||
results.errors.push('Watermarking is disabled');
|
||||
return results;
|
||||
}
|
||||
|
||||
// First, clear existing watermarks from DB (the files will be overwritten)
|
||||
// This ensures stale paths don't persist if regeneration fails
|
||||
|
||||
// Get all image photos (exclude videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
results.status = 'completed';
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log(`Starting watermark regeneration for ${photos.length} photos`);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
// Check if job was cancelled
|
||||
if (!this.activeJobs.has(jobId)) {
|
||||
results.status = 'cancelled';
|
||||
return results;
|
||||
}
|
||||
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error && results.errors.length < 50) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update job status
|
||||
this.activeJobs.set(jobId, { ...results });
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
jobId,
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed,
|
||||
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between batches to prevent CPU saturation
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
results.status = 'completed';
|
||||
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error during watermark regeneration:', error);
|
||||
results.status = 'failed';
|
||||
results.errors.push(error.message);
|
||||
return results;
|
||||
} finally {
|
||||
// Clean up job tracking after a delay
|
||||
setTimeout(() => {
|
||||
this.activeJobs.delete(jobId);
|
||||
}, 60000); // Keep for 1 minute for status queries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all watermarks (when watermarking is disabled)
|
||||
*/
|
||||
async clearAllWatermarks() {
|
||||
try {
|
||||
// Get all photos with watermarks
|
||||
const photos = await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.select('id', 'watermark_path');
|
||||
|
||||
// Delete watermark files
|
||||
for (const photo of photos) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
}
|
||||
|
||||
// Clear database paths
|
||||
await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
|
||||
console.log(`Cleared ${photos.length} watermarks`);
|
||||
return { success: true, cleared: photos.length };
|
||||
} catch (error) {
|
||||
console.error('Error clearing watermarks:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete watermark for a specific photo
|
||||
*/
|
||||
async deleteForPhoto(photoId) {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.select('watermark_path')
|
||||
.first();
|
||||
|
||||
if (photo && photo.watermark_path) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Error deleting watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of an active regeneration job
|
||||
*/
|
||||
getJobStatus(jobId) {
|
||||
return this.activeJobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active regeneration job
|
||||
*/
|
||||
cancelJob(jobId) {
|
||||
if (this.activeJobs.has(jobId)) {
|
||||
this.activeJobs.delete(jobId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an active regeneration job
|
||||
*/
|
||||
hasActiveJob() {
|
||||
for (const [, job] of this.activeJobs) {
|
||||
if (job.status === 'running') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of photos needing watermark generation
|
||||
*/
|
||||
async getPendingCount() {
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await db('photos')
|
||||
.whereNull('watermark_path')
|
||||
.whereNot(function() {
|
||||
this.where('media_type', 'video')
|
||||
.orWhere('mime_type', 'like', 'video/%');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
return parseInt(result.count) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkGeneratorService();
|
||||
@@ -2,6 +2,7 @@ const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkService {
|
||||
constructor() {
|
||||
@@ -232,6 +233,125 @@ class WatermarkService {
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watermarks directory path, creating it if needed
|
||||
*/
|
||||
async getWatermarksDir() {
|
||||
const watermarksDir = path.join(getStoragePath(), 'watermarks');
|
||||
try {
|
||||
await fs.access(watermarksDir);
|
||||
} catch {
|
||||
await fs.mkdir(watermarksDir, { recursive: true });
|
||||
}
|
||||
return watermarksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file extension from a filename
|
||||
*/
|
||||
getFileExtension(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
// Map common extensions
|
||||
if (ext === '.jpeg') return '.jpg';
|
||||
return ext || '.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarked version of a photo and save to disk
|
||||
* @param {Object} photo - Photo object with id, filename, and path info
|
||||
* @param {string} originalPath - Full path to the original image file
|
||||
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
|
||||
* @returns {Object} { success, watermarkPath, error }
|
||||
*/
|
||||
async generateAndSaveWatermark(photo, originalPath, settings = null) {
|
||||
try {
|
||||
// Get settings if not provided
|
||||
if (!settings) {
|
||||
settings = await this.getWatermarkSettings();
|
||||
}
|
||||
|
||||
// If watermarking is disabled, return early
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Verify original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch {
|
||||
return { success: false, watermarkPath: null, error: 'Original file not found' };
|
||||
}
|
||||
|
||||
// Generate watermarked buffer using existing method
|
||||
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
|
||||
|
||||
// Determine output path
|
||||
const watermarksDir = await this.getWatermarksDir();
|
||||
const ext = this.getFileExtension(photo.filename);
|
||||
const outputFilename = `${photo.id}_watermarked${ext}`;
|
||||
const outputPath = path.join(watermarksDir, outputFilename);
|
||||
|
||||
// Write the watermarked image to disk
|
||||
await fs.writeFile(outputPath, watermarkedBuffer);
|
||||
|
||||
// Return relative path for database storage
|
||||
const relativePath = `watermarks/${outputFilename}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
watermarkPath: relativePath,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photo.id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
watermarkPath: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pre-generated watermark file
|
||||
* @param {string} watermarkPath - Relative path to the watermark file
|
||||
* @returns {boolean} - True if deleted successfully
|
||||
*/
|
||||
async deleteWatermarkFile(watermarkPath) {
|
||||
if (!watermarkPath) return false;
|
||||
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), watermarkPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// File might not exist, which is fine
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error deleting watermark file:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash of current watermark settings for change detection
|
||||
* @returns {string} - Hash string of settings
|
||||
*/
|
||||
async getSettingsHash() {
|
||||
const settings = await this.getWatermarkSettings();
|
||||
if (!settings) return '';
|
||||
|
||||
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
|
||||
// Simple hash for change detection (not cryptographic)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hashData.length; i++) {
|
||||
const char = hashData.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkService();
|
||||
@@ -76,7 +76,7 @@ class XmpGenerator {
|
||||
* @returns {string} Description XML
|
||||
*/
|
||||
generateDescription(photo) {
|
||||
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
|
||||
const rating = photo.average_rating ? parseFloat(photo.average_rating).toFixed(1) : '0';
|
||||
const likes = photo.like_count || 0;
|
||||
const favorites = photo.favorite_count || 0;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user