Compare commits

..

50 Commits

Author SHA1 Message Date
Paul Nothaft ff9fb64e75 Merge pull request #273 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m26s
Build and Push Docker Images / build-frontend (push) Failing after 3m28s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.3
2026-04-07 20:40:47 +02:00
github-actions[bot] 9cbbe74051 chore(main): release 2.6.3 2026-04-07 18:40:34 +00:00
Paul Nothaft 2e1c71c1ab Merge pull request #272 from the-luap/docs/external-media-library-270
docs: add External Media Library section to deployment guide (#270)
2026-04-07 20:40:11 +02:00
Paul Nothaft f6ca713a6e docs: add External Media Library section to deployment guide (#270)
Add the missing "External Media Library" chapter to DEPLOYMENT_GUIDE.md
that was referenced in the TOC but never written. Covers configuration,
Docker volume mounting, folder structure, usage workflow, limitations,
and troubleshooting.

Closes #270
2026-04-07 19:52:02 +02:00
Paul Nothaft 197cd8e1e0 Merge pull request #268 from the-luap/security/pin-axios-main
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:41:54 +02:00
Paul Nothaft 681b440381 security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper attributed to North Korean threat actor. Pin to
exact 1.14.0 (latest safe release) to prevent resolution to compromised
versions. See https://github.com/axios/axios/issues/10604
2026-04-05 18:41:45 +02:00
Paul Nothaft 3daeac9e53 Merge pull request #246 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m32s
Build and Push Docker Images / build-frontend (push) Failing after 3m34s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.2
2026-03-16 22:37:56 +01:00
github-actions[bot] 7febba2d9c chore(main): release 2.6.2 2026-03-16 21:37:35 +00:00
Paul Nothaft 0a3a53763c Merge pull request #245 from the-luap/fix/security-session-invalidation-main
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:37:16 +01:00
Paul Nothaft 85a60a2dc7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:36:52 +01:00
Paul Nothaft e74e73a3a0 Merge pull request #231 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:01:15 +01:00
Paul Nothaft 52ab609597 i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions
that were added after the initial Russian localization PR (#216).
2026-03-15 19:48:41 +01:00
Paul Nothaft fafcfbf4e6 Merge pull request #216 from Ih0rd/russian-localization
basic Russian localization
2026-03-15 19:47:25 +01:00
Paul Nothaft 56f497c5f1 Merge pull request #227 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Failing after 3m29s
Build and Push Docker Images / build-frontend (push) Failing after 3m40s
Build and Push Docker Images / summary (push) Waiting to run
chore(main): release 2.6.1
2026-03-11 21:54:39 +01:00
github-actions[bot] b52cf1f741 chore(main): release 2.6.1 2026-03-11 19:48:05 +00:00
Paul Nothaft 7f7736282f Merge pull request #225 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:41 +01:00
Paul Nothaft 67b0f32456 fix: update security policy with proper contact email and private reporting
- Replace placeholder security@example.com with info@picpeak.app
- Add GitHub Private Vulnerability Reporting links
- Update supported versions table to 2.x.x

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

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

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

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

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

Closes #203
2026-03-01 14:36:34 +01:00
Paul Nothaft 0ec3787150 Merge pull request #200 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.2-beta.0
2026-02-23 20:10:12 +01:00
github-actions[bot] 3e0c4fd73e chore(beta): release 3.15.2-beta.0 2026-02-22 21:37:26 +00:00
Paul Nothaft 5ea4ef3cf3 Merge pull request #198 from the-luap/fix/github-issues-194-197
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:11 +01:00
Paul Nothaft cd00bc13d4 fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:27:21 +01:00
44 changed files with 4925 additions and 1200 deletions
@@ -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:
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.15.1-beta.0"
".": "3.17.1-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.5.1"
".": "2.6.3"
}
+74
View File
@@ -5,6 +5,77 @@ 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).
## [2.6.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07)
### Documentation
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16)
### Bug Fixes
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
### Bug Fixes
* 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)
## [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)
@@ -49,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* 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
@@ -96,6 +168,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* 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)
+90 -1
View File
@@ -11,7 +11,7 @@ This guide covers multiple deployment options for PicPeak, from simple local set
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#external-media-library)
- [External Media Library](#-external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
@@ -552,6 +552,95 @@ sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 📂 External Media Library
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
### How It Works
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
### Configuration
Add the following to your `.env` file:
```bash
# Path where your photo library is stored on the host
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
```
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
```yaml
services:
backend:
environment:
- EXTERNAL_MEDIA_ROOT=/external-media
volumes:
- /path/to/your/photos:/external-media:ro # read-only is recommended
```
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
### Folder Structure
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
```
/path/to/your/photos/
├── wedding-smith-2026/
│ ├── individual/
│ │ ├── IMG_0001.jpg
│ │ ├── IMG_0002.jpg
│ │ └── ...
│ └── collages/
│ ├── group-photo.jpg
│ └── ...
├── corporate-event/
│ ├── individual/
│ │ └── ...
│ └── collages/
│ └── ...
```
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
### Usage
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
- Recursively scan the selected folder
- Classify photos by subfolder name (`individual/` or `collages/`)
- Deduplicate by filename (keeps the largest file if duplicates exist)
- Extract image dimensions for gallery layout
- Register the photos in the database
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
### Limitations
- **Images only** — video files are not supported for external media.
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
### Troubleshooting
| Problem | Solution |
|---------|----------|
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
## 🔧 Maintenance
### Viewing Logs
+6 -6
View File
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
## Reporting a Vulnerability
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability by:
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Mark it clearly as "SECURITY" in the title
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+3 -8
View File
@@ -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
@@ -31,12 +27,11 @@ FROM node:20-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
RUN npm install -g npm@10
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
RUN npm install -g npm@latest
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -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();
});
};
+839 -933
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.5.1",
"version": "2.6.3",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -19,7 +19,7 @@
"@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",
@@ -66,7 +66,11 @@
"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.3.8",
"qs": ">=6.14.2",
"tar": ">=7.5.8",
"brace-expansion": ">=5.0.0",
"minimatch": ">=9.0.7"
}
}
+1
View File
@@ -495,6 +495,7 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
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'));
+16 -6
View File
@@ -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);
+4 -2
View File
@@ -122,13 +122,15 @@ 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
});
// Log activity
+29 -27
View File
@@ -162,10 +162,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
return true;
}),
body('event_name').notEmpty().trim(),
body('event_date').optional().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;
@@ -392,9 +392,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_name,
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,
@@ -446,28 +446,30 @@ 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)
// 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({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
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 ? 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
});
if (customerEmail) {
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
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 ? 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
});
}
res.json({
id: eventId,
+151
View File
@@ -0,0 +1,151 @@
const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// 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')
.where(function () {
this.whereNull('width').orWhereNull('height');
})
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.select('id', 'path', 'filename');
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 {
if (!photo.path) {
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
errorCount++;
continue;
}
const storagePath = getStoragePath();
const fullPath = path.join(storagePath, 'events/active', photo.path);
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,
updated_at: db.fn.now()
});
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;
+98 -39
View File
@@ -6,10 +6,11 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
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');
@@ -47,47 +48,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
@@ -111,7 +120,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'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -263,6 +272,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
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),
@@ -273,7 +286,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size, // Use actual file size from stat
captured_at: capturedAt // EXIF capture date (if available)
captured_at: capturedAt, // EXIF capture date (if available)
media_type: mediaType,
mime_type: file.mimetype
};
batchPhotos.push(photoData);
@@ -317,26 +332,65 @@ 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 });
try {
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)
// This pre-generates watermarked versions for fast serving in lightbox
if (insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
// 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));
}
@@ -801,6 +855,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
+21 -15
View File
@@ -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()
});
}
+12 -2
View File
@@ -1169,6 +1169,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: {
@@ -1176,8 +1187,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'));
+2
View File
@@ -78,6 +78,8 @@ router.get('/', async (req, res) => {
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,
+87 -1
View File
@@ -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
};
+3 -8
View File
@@ -12,9 +12,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Set working directory
WORKDIR /app
@@ -30,12 +27,10 @@ COPY . .
# Build the application
RUN npm run build
# Production stage (use Alpine with patched libpng/c-ares)
FROM nginx:1.27-alpine
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
FROM nginx:1.28-alpine
# Upgrade all packages to fix security vulnerabilities
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Install runtime dependencies
+3 -3
View File
@@ -22,10 +22,10 @@ ENV VITE_API_URL=$VITE_API_URL
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
FROM nginx:1.28-alpine
# 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
# Install runtime dependencies
+168 -120
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "3.3.0-beta.0",
"version": "2.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "3.3.0-beta.0",
"version": "2.6.2",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
@@ -20,7 +20,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.12.2",
"axios": "1.14.0",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
"dompurify": "^3.2.6",
@@ -1303,9 +1303,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1317,9 +1317,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1331,9 +1331,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1345,9 +1345,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1359,9 +1359,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1373,9 +1373,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1387,9 +1387,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1401,9 +1401,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1415,9 +1415,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1429,9 +1429,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1443,9 +1443,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1457,9 +1471,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1471,9 +1499,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1485,9 +1513,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1499,9 +1527,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1513,9 +1541,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1526,9 +1554,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1539,10 +1567,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1554,9 +1596,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1568,9 +1610,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1582,9 +1624,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1596,9 +1638,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -2584,13 +2626,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2825,9 +2867,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2978,14 +3020,14 @@
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@@ -4866,9 +4908,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
@@ -4966,9 +5008,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -5709,10 +5751,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -6045,9 +6090,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6061,28 +6106,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -6312,9 +6360,9 @@
}
},
"node_modules/swiper": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.0.tgz",
"integrity": "sha512-BD4CpAOOyEvZ2f0CDx362ea+vmOwukVcmbsQx+0BhRIaBUz8wvcCd//E7RFmvBZCrfyqXCHUVqmgUwts6ywlxw==",
"version": "12.1.2",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz",
"integrity": "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==",
"funding": [
{
"type": "patreon",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.5.1",
"version": "2.6.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -24,7 +24,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.12.2",
"axios": "1.14.0",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
"dompurify": "^3.2.6",
+15 -4
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useMemo } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
@@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface PhotoUploadProps {
eventId: number;
@@ -47,12 +48,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
}, [settings]);
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const acceptString = useMemo(
() => extensionsToAcceptString(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
const imageFiles = files.filter(file =>
allowedMimeTypes.includes(file.type)
);
// Check total file count with existing files
@@ -254,7 +265,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
accept={acceptString}
onChange={handleFileSelect}
className="hidden"
/>
@@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
useCanvasRendering,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
onSelectAll: selectAll,
onDeselectAll: deselectAll,
eventName,
eventLogo,
eventDate,
@@ -1,9 +1,12 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { publicSettingsService } from '../../services/publicSettings.service';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -23,13 +26,28 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000,
});
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
const acceptString = useMemo(
() => extensionsToAcceptString(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate file types
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
const validFiles = selectedFiles.filter(file => {
if (!allowedTypes.includes(file.type)) {
if (!allowedMimeTypes.includes(file.type)) {
toast.error(`Invalid file type: ${file.name}`);
return false;
}
@@ -143,7 +161,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
type="file"
className="hidden"
multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
accept={acceptString}
onChange={handleFileSelect}
disabled={uploading}
/>
@@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps {
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
onSelectAll?: () => void;
onDeselectAll?: () => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string | null;
@@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
onSelectAll,
onDeselectAll,
eventName,
eventDate,
allowDownloads = true,
@@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleSelectAll = useCallback(() => {
if (selectedPhotos.size === filteredPhotos.length) {
// Deselect all
filteredPhotos.forEach(p => onPhotoSelect?.(p.id));
onDeselectAll?.();
} else {
// Select all
filteredPhotos.forEach(p => {
if (!selectedPhotos.has(p.id)) {
onPhotoSelect?.(p.id);
}
});
onSelectAll?.();
}
}, [selectedPhotos, filteredPhotos, onPhotoSelect]);
}, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]);
const handleDownloadSelected = useCallback(async () => {
if (selectedPhotos.size === 0) return;
+1
View File
@@ -14,3 +14,4 @@ export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
@@ -7,9 +7,12 @@ import {
Clock,
HardDrive,
Activity,
Ruler,
} from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../../../config/api';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
@@ -53,6 +56,27 @@ export const StatusTab: React.FC<StatusTabProps> = ({
}) => {
const { t } = useTranslation();
const { storageInfo, systemStatus } = useStatusTab(isActive);
const queryClient = useQueryClient();
const { data: dimensionStatus } = useQuery({
queryKey: ['photo-dimension-status'],
queryFn: async () => {
const res = await api.get('/admin/photos/repair-dimensions/status');
return res.data;
},
enabled: isActive,
refetchInterval: 10000,
});
const repairMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/admin/photos/repair-dimensions');
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-dimension-status'] });
},
});
// Sync soft limit from storage info
useEffect(() => {
@@ -528,6 +552,61 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</>
)}
{/* Photo Dimensions */}
{dimensionStatus && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Ruler className="w-5 h-5" />
{t('settings.photoDimensions.title')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dimensionStatus.total}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.totalPhotos')}</p>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{dimensionStatus.withDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.withDimensions')}</p>
</div>
<div className={`rounded-lg p-3 text-center ${Number(dimensionStatus.withoutDimensions) > 0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}>
<p className={`text-2xl font-bold ${Number(dimensionStatus.withoutDimensions) > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.missingDimensions')}</p>
</div>
</div>
{dimensionStatus.lastResult && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.resultSuccess', {
success: dimensionStatus.lastResult.success,
failed: dimensionStatus.lastResult.failed,
})}
</p>
)}
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => repairMutation.mutate()}
isLoading={repairMutation.isPending || dimensionStatus.isRunning}
disabled={Number(dimensionStatus.withoutDimensions) === 0 || dimensionStatus.isRunning}
leftIcon={<Ruler className="w-4 h-4" />}
>
{dimensionStatus.isRunning
? t('settings.photoDimensions.repairing')
: Number(dimensionStatus.withoutDimensions) === 0
? t('settings.photoDimensions.noneToRepair')
: t('settings.photoDimensions.repairButton')}
</Button>
</div>
</Card>
)}
{/* Update Notification Settings */}
<UpdateNotificationSettings />
@@ -0,0 +1,292 @@
import React, { useState, useEffect } from 'react';
import { Save, Image, RefreshCw, AlertCircle, Loader2 } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { api } from '../../../config/api';
interface ThumbnailSettings {
width: number;
height: number;
quality: number;
fit: string;
format: string;
}
const defaultSettings: ThumbnailSettings = {
width: 300,
height: 300,
quality: 85,
fit: 'cover',
format: 'jpeg',
};
interface FetchedSettings {
settings: Record<string, { value: string; description: string }>;
fitOptions: string[];
formatOptions: string[];
}
export const ThumbnailsTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [settings, setSettings] = useState<ThumbnailSettings>(defaultSettings);
const [isDirty, setIsDirty] = useState(false);
const { data: fetchedData, isLoading, error } = useQuery<FetchedSettings>({
queryKey: ['thumbnail-settings'],
queryFn: async () => {
const response = await api.get('/admin/thumbnails/settings');
return response.data;
},
});
useEffect(() => {
if (fetchedData?.settings) {
const s = fetchedData.settings;
setSettings({
width: parseInt(s.thumbnail_width?.value) || defaultSettings.width,
height: parseInt(s.thumbnail_height?.value) || defaultSettings.height,
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
fit: s.thumbnail_fit?.value || defaultSettings.fit,
format: s.thumbnail_format?.value || defaultSettings.format,
});
}
}, [fetchedData]);
const saveMutation = useMutation({
mutationFn: async (newSettings: ThumbnailSettings) => {
const response = await api.put('/admin/thumbnails/settings', newSettings);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['thumbnail-settings'] });
toast.success(t('settings.thumbnails.saveSuccess', 'Thumbnail settings saved'));
setIsDirty(false);
},
onError: () => {
toast.error(t('settings.thumbnails.saveError', 'Failed to save thumbnail settings'));
},
});
const regenerateMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/thumbnails/regenerate');
return response.data;
},
onSuccess: (data) => {
toast.success(data.message || t('settings.thumbnails.regenerateStarted', 'Thumbnail regeneration started'));
},
onError: () => {
toast.error(t('settings.thumbnails.regenerateError', 'Failed to start thumbnail regeneration'));
},
});
const handleChange = <K extends keyof ThumbnailSettings>(
key: K,
value: ThumbnailSettings[K]
) => {
setSettings(prev => ({ ...prev, [key]: value }));
setIsDirty(true);
};
const handleSave = () => {
saveMutation.mutate(settings);
};
const handleReset = () => {
if (fetchedData?.settings) {
const s = fetchedData.settings;
setSettings({
width: parseInt(s.thumbnail_width?.value) || defaultSettings.width,
height: parseInt(s.thumbnail_height?.value) || defaultSettings.height,
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
fit: s.thumbnail_fit?.value || defaultSettings.fit,
format: s.thumbnail_format?.value || defaultSettings.format,
});
setIsDirty(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" text={t('common.loading')} />
</div>
);
}
if (error) {
return (
<Card padding="md">
<div className="flex items-center gap-3 text-red-600">
<AlertCircle className="w-5 h-5" />
<p>{t('settings.thumbnails.loadError', 'Failed to load thumbnail settings')}</p>
</div>
</Card>
);
}
const fitOptions = fetchedData?.fitOptions || ['cover', 'contain', 'fill', 'inside', 'outside'];
const formatOptions = fetchedData?.formatOptions || ['jpeg', 'png', 'webp'];
return (
<div className="space-y-6">
{/* Dimensions & Quality */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Image className="w-5 h-5 text-primary-600" />
{t('settings.thumbnails.dimensionsTitle', 'Thumbnail Dimensions & Quality')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.thumbnails.dimensionsHelp', 'Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.thumbnails.width', 'Width (px)')}
</label>
<input
type="number"
min="50"
max="1000"
value={settings.width}
onChange={(e) => handleChange('width', parseInt(e.target.value) || 300)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.thumbnails.widthHelp', '50-1000 pixels')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.thumbnails.height', 'Height (px)')}
</label>
<input
type="number"
min="50"
max="1000"
value={settings.height}
onChange={(e) => handleChange('height', parseInt(e.target.value) || 300)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.thumbnails.heightHelp', '50-1000 pixels')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.thumbnails.quality', 'Quality')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.quality}
onChange={(e) => handleChange('quality', parseInt(e.target.value) || 85)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.thumbnails.qualityHelp', '1-100, higher = better quality but larger files')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.thumbnails.format', 'Format')}
</label>
<select
value={settings.format}
onChange={(e) => handleChange('format', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{formatOptions.map((fmt) => (
<option key={fmt} value={fmt}>{fmt.toUpperCase()}</option>
))}
</select>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.thumbnails.fit', 'Fit Mode')}
</label>
<select
value={settings.fit}
onChange={(e) => handleChange('fit', e.target.value)}
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{fitOptions.map((opt) => (
<option key={opt} value={opt}>
{t(`settings.thumbnails.fit_${opt}`, opt.charAt(0).toUpperCase() + opt.slice(1))}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.thumbnails.fitHelp', 'How images are resized to fit the thumbnail dimensions. "Cover" crops to fill, "Contain" fits within bounds.')}
</p>
</div>
</Card>
{/* Regenerate */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<RefreshCw className="w-5 h-5 text-primary-600" />
{t('settings.thumbnails.regenerateTitle', 'Regenerate Thumbnails')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.thumbnails.regenerateHelp', 'After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.')}
</p>
<Button
variant="outline"
onClick={() => regenerateMutation.mutate()}
isLoading={regenerateMutation.isPending}
leftIcon={regenerateMutation.isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
>
{t('settings.thumbnails.regenerateButton', 'Regenerate All Thumbnails')}
</Button>
</Card>
{/* Info Box */}
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.thumbnails.infoTitle', 'About Thumbnails')}</p>
<p>
{t('settings.thumbnails.infoText', 'Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use "Regenerate All Thumbnails" to update existing photos.')}
</p>
</div>
</div>
</Card>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
variant="primary"
onClick={handleSave}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
disabled={!isDirty}
>
{t('common.saveChanges', 'Save Changes')}
</Button>
{isDirty && (
<Button
variant="outline"
onClick={handleReset}
leftIcon={<RefreshCw className="w-5 h-5" />}
>
{t('common.resetChanges', 'Reset Changes')}
</Button>
)}
</div>
</div>
);
};
+42
View File
@@ -1218,6 +1218,35 @@
"hidePreview": "robots.txt-Vorschau ausblenden",
"saveSettings": "SEO-Einstellungen speichern"
},
"thumbnails": {
"title": "Vorschaubilder",
"dimensionsTitle": "Abmessungen & Qualität der Vorschaubilder",
"dimensionsHelp": "Konfigurieren Sie Größe und Qualität der automatisch generierten Vorschaubilder. Höhere Werte erzeugen bessere Vorschauen, erhöhen aber den Speicherbedarf und die Ladezeiten.",
"width": "Breite (px)",
"widthHelp": "50-1000 Pixel",
"height": "Höhe (px)",
"heightHelp": "50-1000 Pixel",
"quality": "Qualität",
"qualityHelp": "1-100, höher = bessere Qualität, aber größere Dateien",
"format": "Format",
"fit": "Anpassungsmodus",
"fitHelp": "Wie Bilder an die Vorschaubildgröße angepasst werden. \"Füllen\" beschneidet das Bild, \"Einpassen\" passt es innerhalb der Grenzen an.",
"fit_cover": "Füllen (zuschneiden)",
"fit_contain": "Einpassen (innerhalb)",
"fit_fill": "Strecken",
"fit_inside": "Innen (verkleinern)",
"fit_outside": "Außen (vergrößern)",
"regenerateTitle": "Vorschaubilder neu generieren",
"regenerateHelp": "Nach dem Ändern der Einstellungen können Sie alle vorhandenen Vorschaubilder neu generieren. Dies läuft im Hintergrund und kann bei großen Galerien eine Weile dauern.",
"regenerateButton": "Alle Vorschaubilder neu generieren",
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
"saveSuccess": "Vorschaubild-Einstellungen gespeichert",
"saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden",
"loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden",
"infoTitle": "Über Vorschaubilder",
"infoText": "Vorschaubilder sind kleinere Vorschauversionen Ihrer Originalbilder. Das Erhöhen der Größe oder Qualität verbessert die Darstellung in der Galerieübersicht, benötigt aber mehr Speicherplatz und Bandbreite. Nach dem Ändern der Einstellungen verwenden Sie \"Alle Vorschaubilder neu generieren\", um bestehende Fotos zu aktualisieren."
},
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
@@ -1250,6 +1279,19 @@
"failed": "Fehlgeschlagen",
"lastUpdate": "Letzte Aktualisierung"
},
"photoDimensions": {
"title": "Foto-Abmessungen",
"totalPhotos": "Fotos gesamt",
"withDimensions": "Mit Abmessungen",
"missingDimensions": "Fehlende Abmessungen",
"repairButton": "Abmessungen reparieren",
"repairing": "Repariere...",
"alreadyRunning": "Reparatur läuft bereits",
"started": "Reparatur von {{count}} Fotos gestartet",
"noneToRepair": "Alle Fotos haben bereits Abmessungen",
"resultSuccess": "Letzte Reparatur: {{success}} aktualisiert, {{failed}} fehlgeschlagen",
"description": "Fehlende Breite/Höhe für Fotos ergänzen, die vor der Dimensionsverfolgung hochgeladen wurden. Erforderlich für Masonry- und Mosaik-Layouts."
},
"updateNotifications": {
"title": "Update-Benachrichtigungen",
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
+42
View File
@@ -799,6 +799,19 @@
"failed": "Failed",
"lastUpdate": "Last update"
},
"photoDimensions": {
"title": "Photo Dimensions",
"totalPhotos": "Total Photos",
"withDimensions": "With Dimensions",
"missingDimensions": "Missing Dimensions",
"repairButton": "Repair Dimensions",
"repairing": "Repairing...",
"alreadyRunning": "Repair is already running",
"started": "Started repairing {{count}} photos",
"noneToRepair": "All photos already have dimensions",
"resultSuccess": "Last repair: {{success}} updated, {{failed}} failed",
"description": "Backfill missing width/height for photos uploaded before dimension tracking was added. Required for Masonry and Mosaic layouts."
},
"updateNotifications": {
"title": "Update Notifications",
"description": "Receive email notifications when new versions of PicPeak are available.",
@@ -898,6 +911,35 @@
"styling": {
"title": "Custom CSS"
},
"thumbnails": {
"title": "Thumbnails",
"dimensionsTitle": "Thumbnail Dimensions & Quality",
"dimensionsHelp": "Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.",
"width": "Width (px)",
"widthHelp": "50-1000 pixels",
"height": "Height (px)",
"heightHelp": "50-1000 pixels",
"quality": "Quality",
"qualityHelp": "1-100, higher = better quality but larger files",
"format": "Format",
"fit": "Fit Mode",
"fitHelp": "How images are resized to fit the thumbnail dimensions. \"Cover\" crops to fill, \"Contain\" fits within bounds.",
"fit_cover": "Cover (crop to fill)",
"fit_contain": "Contain (fit within)",
"fit_fill": "Fill (stretch)",
"fit_inside": "Inside (shrink to fit)",
"fit_outside": "Outside (expand to cover)",
"regenerateTitle": "Regenerate Thumbnails",
"regenerateHelp": "After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.",
"regenerateButton": "Regenerate All Thumbnails",
"regenerateStarted": "Thumbnail regeneration started",
"regenerateError": "Failed to start thumbnail regeneration",
"saveSuccess": "Thumbnail settings saved",
"saveError": "Failed to save thumbnail settings",
"loadError": "Failed to load thumbnail settings",
"infoTitle": "About Thumbnails",
"infoText": "Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use \"Regenerate All Thumbnails\" to update existing photos."
},
"seo": {
"title": "SEO & Robots",
"indexingTitle": "Search Engine Indexing",
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -13,9 +13,10 @@ import {
ModerationTab,
StylingTab,
SEOTab,
ThumbnailsTab,
} from '../../features/settings';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling';
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general');
@@ -74,6 +75,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'status', label: t('settings.systemStatus.title') },
{ key: 'security', label: t('settings.security.title') },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails') },
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots') },
{ key: 'categories', label: t('settings.categories.title') },
{ key: 'analytics', label: t('settings.analytics.title') },
@@ -168,6 +170,8 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
{activeTab === 'thumbnails' && <ThumbnailsTab />}
{activeTab === 'categories' && <CategoriesTab />}
{activeTab === 'analytics' && (
@@ -23,6 +23,8 @@ export interface PublicSettings {
umami_url: string | null;
umami_website_id: string | null;
umami_share_url: string | null;
// Upload settings
allowed_file_types?: string;
// Event field requirements
event_require_customer_name?: boolean;
event_require_customer_email?: boolean;
@@ -149,7 +149,11 @@ export const userManagementService = {
* Update an admin user
*/
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
// Convert camelCase to snake_case for backend API
const payload: Record<string, unknown> = {};
if (data.roleId !== undefined) payload.role_id = data.roleId;
if (data.isActive !== undefined) payload.is_active = data.isActive;
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, payload);
return transformUser(response.data.user);
},
+48
View File
@@ -0,0 +1,48 @@
/**
* Maps file extensions to MIME types for upload validation.
*/
const EXTENSION_TO_MIME: Record<string, string> = {
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 = 'jpg,jpeg,png,webp';
/**
* Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an
* array of unique MIME types.
*/
export function extensionsToMimeTypes(extString?: string | null): string[] {
const input = extString?.trim() || DEFAULT_ALLOWED;
const mimeSet = new Set<string>();
input.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);
}
return Array.from(mimeSet);
}
/**
* Convert a comma-separated extension string to an HTML `accept` attribute
* value, e.g. "image/jpeg,image/png,video/mp4".
*/
export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(',');
}
Binary file not shown.
+115
View File
@@ -0,0 +1,115 @@
import { test, expect, Page } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
async function getAdminToken(page: Page): Promise<string> {
const res = await page.request.post('/api/auth/admin/login', {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok()).toBeTruthy();
const { token } = await res.json();
return token;
}
test.describe('Admin video upload (#203)', () => {
test('Videos uploaded via admin have correct media_type and mime_type', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip();
}
const token = await getAdminToken(page);
// Create event
const eventName = `PW Video ${Date.now()}`;
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const createRes = await page.request.post('/api/admin/events', {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
event_type: 'wedding',
event_name: eventName,
event_date: eventDate,
customer_name: 'Playwright Host',
customer_email: 'host@example.com',
admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD,
expiration_days: 90,
allow_downloads: true,
},
});
expect(createRes.ok()).toBeTruthy();
const event = await createRes.json();
expect(event.id).toBeTruthy();
// Upload a video via admin endpoint
const videoPath = path.join(process.cwd(), 'test-assets', 'test-video.mp4');
expect(fs.existsSync(videoPath)).toBeTruthy();
const buffer = fs.readFileSync(videoPath);
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
headers: { Authorization: `Bearer ${token}` },
multipart: {
photos: { name: 'test-video.mp4', mimeType: 'video/mp4', buffer },
category_id: 'individual',
},
});
expect(uploadRes.ok()).toBeTruthy();
const uploadBody = await uploadRes.json();
expect(uploadBody.successCount).toBeGreaterThanOrEqual(1);
// Wait for background processing
await page.waitForTimeout(5000);
// Fetch photos for this event via admin API
const photosRes = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(photosRes.ok()).toBeTruthy();
const photosBody = await photosRes.json();
const photos = photosBody.photos || photosBody;
expect(photos.length).toBeGreaterThanOrEqual(1);
// Find our video — the critical fix: media_type and mime_type must be set
const video = photos.find((p: any) => p.media_type === 'video');
expect(video).toBeTruthy();
expect(video.media_type).toBe('video');
expect(video.mime_type).toBe('video/mp4');
// width/height/duration depend on ffprobe being available in the environment;
// if present they should be positive, but we don't fail on missing ffprobe
if (video.width !== null) {
expect(video.width).toBeGreaterThan(0);
expect(video.height).toBeGreaterThan(0);
}
// Also upload an image and verify it gets media_type = 'image' with dimensions
const imgBuffer = fs.readFileSync(path.join(process.cwd(), 'test-assets', 'img1.png'));
const imgUploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
headers: { Authorization: `Bearer ${token}` },
multipart: {
photos: { name: 'img1.png', mimeType: 'image/png', buffer: imgBuffer },
category_id: 'individual',
},
});
expect(imgUploadRes.ok()).toBeTruthy();
await page.waitForTimeout(2000);
// Re-fetch and verify image has correct media_type and dimensions
const photosRes2 = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(photosRes2.ok()).toBeTruthy();
const photosBody2 = await photosRes2.json();
const photos2 = photosBody2.photos || photosBody2;
const image = photos2.find((p: any) => p.media_type === 'image');
expect(image).toBeTruthy();
expect(image.media_type).toBe('image');
expect(image.width).toBeGreaterThan(0);
expect(image.height).toBeGreaterThan(0);
});
});
@@ -0,0 +1,114 @@
import { test, expect, Page } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
async function getAdminToken(page: Page): Promise<string> {
const res = await page.request.post('/api/auth/admin/login', {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok()).toBeTruthy();
const { token } = await res.json();
return token;
}
async function createGalleryPremiumEvent(page: Page) {
const token = await getAdminToken(page);
const eventName = `PW SelectAll ${Date.now()}`;
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const createRes = await page.request.post('/api/admin/events', {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
event_type: 'wedding',
event_name: eventName,
event_date: eventDate,
customer_name: 'Playwright Host',
customer_email: 'host@example.com',
admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD,
expiration_days: 90,
allow_downloads: true,
gallery_theme: 'gallery-premium',
},
});
expect(createRes.ok()).toBeTruthy();
const event = await createRes.json();
// Upload 3 images so we can verify select-all picks all of them
const imagePaths = ['img1.png', 'img2.png', 'img1.png'].map((f) =>
path.join(process.cwd(), 'test-assets', f)
);
for (const imagePath of imagePaths) {
const buffer = fs.readFileSync(imagePath);
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
headers: { Authorization: `Bearer ${token}` },
multipart: {
photos: { name: path.basename(imagePath), mimeType: 'image/png', buffer },
category_id: 'individual',
},
});
expect(uploadRes.ok()).toBeTruthy();
}
return { shareLink: event.share_link, slug: event.slug, token };
}
test.describe('Gallery-Premium Select All (#220)', () => {
test('Select All button selects all photos in one click', async ({ page, browserName }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip();
}
const { shareLink } = await createGalleryPremiumEvent(page);
// Navigate to gallery
await page.goto(shareLink);
await page.waitForLoadState('domcontentloaded');
// Handle password if needed
const passwordField = page.getByPlaceholder(/gallery password/i).first();
if (await passwordField.count()) {
await passwordField.fill(GALLERY_PASSWORD);
try {
await page.getByRole('button', { name: /View Gallery/i }).click({ timeout: 5000 });
} catch {
// Token may auto-auth
}
await page.waitForLoadState('networkidle');
}
// Wait for photos to render
await page.waitForTimeout(3000);
// Look for download button to enter selection mode
const downloadBtn = page.getByRole('button', { name: /Download/i }).first();
await expect(downloadBtn).toBeVisible({ timeout: 15000 });
await downloadBtn.click();
// Now look for "Select All" button
const selectAllBtn = page.getByRole('button', { name: /Select All/i }).first();
await expect(selectAllBtn).toBeVisible({ timeout: 10000 });
// Click Select All once
await selectAllBtn.click();
await page.waitForTimeout(500);
// Verify all photos are selected - check for checkmarks or selected state
// The selection count or "Deselect All" text should appear
const deselectAllBtn = page.getByRole('button', { name: /Deselect All/i }).first();
await expect(deselectAllBtn).toBeVisible({ timeout: 5000 });
// Verify: clicking Deselect All should clear selection
await deselectAllBtn.click();
await page.waitForTimeout(500);
// Select All should be visible again
await expect(selectAllBtn).toBeVisible({ timeout: 5000 });
});
});
@@ -0,0 +1,151 @@
import { test, expect, Page } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
async function getAdminToken(page: Page): Promise<string> {
const res = await page.request.post('/api/auth/admin/login', {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.token).toBeTruthy();
return body.token;
}
async function updateEventSettings(
page: Page,
token: string,
settings: Record<string, boolean>
) {
const res = await page.request.put('/api/admin/settings/general', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: settings,
});
expect(res.ok()).toBeTruthy();
}
test.describe('Optional email fields in event creation (#217)', () => {
test('event creation succeeds with empty emails when set to optional', async ({ page }) => {
const token = await getAdminToken(page);
// Disable email requirements
await updateEventSettings(page, token, {
event_require_customer_email: false,
event_require_admin_email: false,
});
try {
// Create event with empty email fields
const eventRes = await page.request.post('/api/admin/events', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
event_type: 'wedding',
event_name: `E2E Optional Emails ${Date.now()}`,
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
customer_name: 'Test Host',
customer_email: '',
admin_email: '',
password: 'TestPass123!',
expiration_days: 30,
},
});
const body = await eventRes.json();
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
expect(body.id).toBeTruthy();
// Cleanup: delete the created event
await page.request.delete(`/api/admin/events/${body.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
} finally {
// Revert settings to required
await updateEventSettings(page, token, {
event_require_customer_email: true,
event_require_admin_email: true,
});
}
});
test('event creation still fails with empty emails when set to required', async ({ page }) => {
const token = await getAdminToken(page);
// Ensure email requirements are enabled
await updateEventSettings(page, token, {
event_require_customer_email: true,
event_require_admin_email: true,
});
const eventRes = await page.request.post('/api/admin/events', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
event_type: 'wedding',
event_name: `E2E Required Emails ${Date.now()}`,
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
customer_name: 'Test Host',
customer_email: '',
admin_email: '',
password: 'TestPass123!',
expiration_days: 30,
},
});
expect(eventRes.status()).toBe(400);
const body = await eventRes.json();
const paths = body.errors.map((e: { path: string }) => e.path);
expect(paths).toContain('customer_email');
expect(paths).toContain('admin_email');
});
test('event creation succeeds with missing email fields when optional', async ({ page }) => {
const token = await getAdminToken(page);
// Disable email requirements
await updateEventSettings(page, token, {
event_require_customer_email: false,
event_require_admin_email: false,
});
try {
// Create event without email fields at all (undefined, not empty string)
const eventRes = await page.request.post('/api/admin/events', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
event_type: 'wedding',
event_name: `E2E Missing Emails ${Date.now()}`,
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
customer_name: 'Test Host',
password: 'TestPass123!',
expiration_days: 30,
},
});
const body = await eventRes.json();
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
expect(body.id).toBeTruthy();
// Cleanup
await page.request.delete(`/api/admin/events/${body.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
} finally {
await updateEventSettings(page, token, {
event_require_customer_email: true,
event_require_admin_email: true,
});
}
});
});
+64
View File
@@ -0,0 +1,64 @@
import { test, expect, Page } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
async function getAdminToken(page: Page): Promise<string> {
const res = await page.request.post('/api/auth/admin/login', {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok()).toBeTruthy();
const { token } = await res.json();
return token;
}
test.describe('Photo Dimensions Repair (#180)', () => {
test('Status endpoint returns dimension counts', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip();
}
const token = await getAdminToken(page);
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
headers: { Authorization: `Bearer ${token}` },
});
expect(statusRes.ok()).toBeTruthy();
const status = await statusRes.json();
expect(status).toHaveProperty('total');
expect(status).toHaveProperty('withDimensions');
expect(status).toHaveProperty('withoutDimensions');
expect(status).toHaveProperty('isRunning');
expect(typeof status.total).toBe('number');
expect(typeof status.isRunning).toBe('boolean');
});
test('Repair endpoint runs and returns immediately', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip();
}
const token = await getAdminToken(page);
const repairRes = await page.request.post('/api/admin/photos/repair-dimensions', {
headers: { Authorization: `Bearer ${token}` },
});
expect(repairRes.ok()).toBeTruthy();
const body = await repairRes.json();
expect(body).toHaveProperty('message');
expect(body).toHaveProperty('count');
// Wait for background job to complete
await page.waitForTimeout(3000);
// Check status after repair
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
headers: { Authorization: `Bearer ${token}` },
});
expect(statusRes.ok()).toBeTruthy();
const status = await statusRes.json();
expect(status.isRunning).toBe(false);
});
});