Compare commits

...

37 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

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

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

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

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

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

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

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

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

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
59 changed files with 2631 additions and 441 deletions
+15
View File
@@ -39,6 +39,11 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
@@ -50,6 +55,16 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
# 'beta' uses the :beta tag for pre-release versions
PICPEAK_CHANNEL=stable
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
+37 -9
View File
@@ -8,10 +8,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, develop ]
tags: [ 'v*.*.*' ] # Triggered by Release Please tags
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main ]
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -42,6 +42,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
@@ -88,10 +100,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
@@ -123,7 +137,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
@@ -139,6 +153,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
@@ -185,10 +211,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
@@ -220,7 +248,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
+37
View File
@@ -0,0 +1,37 @@
name: Release Please (Beta)
on:
push:
branches: [beta]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
-35
View File
@@ -34,38 +34,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Sync version to package.json files after release
sync-versions:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Update package.json versions
run: |
VERSION="${{ needs.release-please.outputs.version }}"
echo "Updating package.json files to version $VERSION"
# Update backend package.json
cd backend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
# Update frontend package.json
cd frontend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
- name: Commit version updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
git push
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.2.1-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.2.4"
".": "2.3.0"
}
+240
View File
@@ -5,6 +5,246 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
### Bug Fixes
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
### Features
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
### Bug Fixes
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
### Features
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
### Bug Fixes
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
### Bug Fixes
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
### ⚠ BREAKING CHANGES
* Deployment now requires external reverse proxy for SSL/HTTPS
### Features
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
### Bug Fixes
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
* handle auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* remove description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
* resolve CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
### Documentation
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
### Code Refactoring
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
## [2.3.0](https://github.com/the-luap/picpeak/compare/v2.2.4...v2.3.0) (2026-01-15)
### Features
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
### Bug Fixes
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
## [2.2.4](https://github.com/the-luap/picpeak/compare/v2.2.3...v2.2.4) (2026-01-08)
+116 -148
View File
@@ -2,9 +2,24 @@
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
## 📋 Table of Contents
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
@@ -12,27 +27,11 @@ chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This automated script handles everything including:
- Choice between Docker or Native installation
- OS detection and dependency installation
- Database setup and service configuration
- SSL/HTTPS setup (optional)
Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users new to server management
- Quick testing and evaluation
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
@@ -43,35 +42,40 @@ cd picpeak
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 2: Building from Source
### Option 3: Build from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
## 📋 Table of Contents
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [First Login](#first-login)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Maintenance](#maintenance)
- [Troubleshooting](#troubleshooting)
- [External Media Library](#external-media-library)
docker compose build
docker compose up -d
```
## Prerequisites
@@ -80,106 +84,6 @@ If you need to customize the application or the pre-built images aren't availabl
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🚀 Quick Start
### Method 1: Using Pre-built Images (Fastest)
1. **Clone the repository for configs**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Deploy using pre-built images**
```bash
docker compose -f docker-compose.production.yml up -d
```
5. **Check logs**
```bash
docker compose -f docker-compose.production.yml logs -f
```
## External Media Library
PicPeak can reference an existing, readonly media library mounted into the backend container. This avoids copying originals into PicPeak storage.
- Map your host library path to the container as readonly in `docker-compose.production.yml`:
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
- In `.env`, set:
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
- `EXTERNAL_MEDIA_ROOT=/external-media`
Usage:
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
Backups and Archives:
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
- Archiving reference events creates a manifestonly ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
Local (npm) setup (no Docker):
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
2. In `backend/.env` (or your shell), set:
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
3. Start services from source:
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
4. In Admin → Events:
- Create an event, set “Source Mode” to “Reference (external folder)”.
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
- Click “Import from selected folder” to index files and generate thumbnails on demand.
Notes:
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
### Method 2: Building from Source
1. **Clone the repository**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
5. **Check logs**
```bash
docker compose logs -f
```
## 🔧 Configuration
### Essential Environment Variables
@@ -358,14 +262,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- The admin password is only displayed once during initial setup
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
@@ -446,6 +352,61 @@ ADMIN_EMAIL=your-email@yourdomain.com
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
@@ -656,20 +617,27 @@ docker compose up -d
docker compose ps
```
#### Specific Version Updates
#### Specific Version or Channel Updates
To use a specific version of the images:
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit docker-compose.production.yml to specify version tags
# Change: ghcr.io/the-luap/picpeak/backend:latest
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
+45 -1
View File
@@ -79,7 +79,51 @@ Note on Docker file permissions (PUID/PGID)
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with Permission denied.
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker-compose -f docker-compose.production.yml pull
docker-compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```bash
UPDATE_CHECK_ENABLED=false
```
## 📖 Documentation
+2
View File
@@ -451,6 +451,8 @@ cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
### Getting Help
1. **Check logs:**
+5
View File
@@ -14,6 +14,11 @@ ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
+2 -1
View File
@@ -1,6 +1,6 @@
# Database Migrations
This directory contains database migrations for the Wedding Photo Sharing platform.
This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
@@ -3,6 +3,81 @@
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// First, ensure the email_templates table has multilingual columns
// This is needed for fresh installations where legacy migrations don't run
const columnInfo = await knex('email_templates').columnInfo();
if (!columnInfo.subject_en) {
// Need to add multilingual columns
console.log('Adding multilingual columns to email_templates table...');
// Check if we're using SQLite or PostgreSQL
const client = knex.client.config.client;
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
if (isSqlite) {
// SQLite doesn't support column rename directly in all versions
// We need to recreate the table with new structure
// Get existing data
const existingData = await knex('email_templates').select('*');
// Drop the old table
await knex.schema.dropTable('email_templates');
// Create new table with multilingual columns
await knex.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable();
table.string('subject_en');
table.string('subject_de');
table.text('body_html_en');
table.text('body_html_de');
table.text('body_text_en');
table.text('body_text_de');
table.json('variables');
table.datetime('updated_at').defaultTo(knex.fn.now());
});
// Re-insert existing data with column mapping
for (const row of existingData) {
await knex('email_templates').insert({
template_key: row.template_key,
subject_en: row.subject,
subject_de: row.subject, // Copy to German as default
body_html_en: row.body_html,
body_html_de: row.body_html,
body_text_en: row.body_text,
body_text_de: row.body_text,
variables: row.variables,
updated_at: row.updated_at
});
}
console.log('Migrated email_templates table to multilingual structure');
} else {
// PostgreSQL supports ALTER TABLE for column operations
await knex.schema.alterTable('email_templates', (table) => {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
await knex.schema.alterTable('email_templates', (table) => {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy English values to German as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
@@ -0,0 +1,53 @@
/**
* Migration: Add optional event date and expiration settings
* These settings control whether event_date and expiration are required
* when creating new events, supporting non-event use cases like portraits.
*/
exports.up = async function(knex) {
// Add new settings for optional date and expiration
const settings = [
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
// Make event_date and expires_at columns nullable
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
// SQLite requires table recreation (handled differently)
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// PostgreSQL: directly alter columns
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite: columns are already effectively nullable in most cases
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
// For safety, we'll skip the schema change for SQLite as it's complex
// The application logic will handle null values appropriately
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
}
};
exports.down = async function(knex) {
// Remove the settings
await knex('app_settings')
.whereIn('setting_key', [
'event_require_event_date',
'event_require_expiration'
])
.del();
// Note: We don't restore NOT NULL constraints as that could fail
// if there are existing NULL values in the database
};
@@ -0,0 +1,28 @@
/**
* Migration 061: Add pre-generated watermark path to photos table
* - photos.watermark_path: path to pre-generated watermarked image
* - photos.watermark_generated_at: timestamp of watermark generation
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 061_add_watermark_path');
// photos.watermark_path (nullable - path to pre-generated watermarked image)
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
table.string('watermark_path', 512);
});
// photos.watermark_generated_at (nullable - when watermark was last generated)
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
table.timestamp('watermark_generated_at');
});
console.log('Migration 061_add_watermark_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 061_add_watermark_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
}
};
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Check which columns already exist to make migration idempotent
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
// PostgreSQL error codes:
// - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename });
// Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`);
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.2.3",
"version": "3.2.1-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"lint": "eslint src/"
},
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Script to generate pre-watermarked versions for existing photos
* This is a one-time migration script to populate watermarks for photos
* that existed before the pre-generation feature was implemented.
*
* Usage: node scripts/generate-watermarks.js [eventId]
*
* Options:
* eventId - Optional: Only generate watermarks for a specific event
*
* Examples:
* node scripts/generate-watermarks.js # Generate for all photos
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
*/
const path = require('path');
const { db } = require('../src/database/db');
const watermarkService = require('../src/services/watermarkService');
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
async function generateWatermarks(eventId = null) {
try {
console.log('='.repeat(60));
console.log('PicPeak Watermark Generation Script');
console.log('='.repeat(60));
// Check if watermarking is enabled
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
console.log('\nWatermarking is currently DISABLED in settings.');
console.log('Enable watermarking in Admin > Branding settings first.');
console.log('Exiting without generating watermarks.');
process.exit(0);
}
console.log('\nWatermark Settings:');
console.log(` Enabled: ${settings.enabled}`);
console.log(` Position: ${settings.position}`);
console.log(` Opacity: ${settings.opacity}%`);
console.log(` Size: ${settings.size}%`);
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNull('photos.watermark_path')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.id',
'photos.filename',
'photos.event_id',
'events.event_name'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`\nFiltering to event ID: ${eventId}`);
}
const photos = await query;
if (photos.length === 0) {
console.log('\nNo photos found without watermarks.');
if (eventId) {
console.log(`(Checked event ID: ${eventId})`);
}
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
process.exit(0);
}
console.log(`\nFound ${photos.length} photos without watermarks.`);
// Group by event for display
const eventCounts = {};
photos.forEach(p => {
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
});
console.log('\nPhotos by event:');
Object.entries(eventCounts).forEach(([name, count]) => {
console.log(` ${name}: ${count} photos`);
});
console.log('\nStarting watermark generation...\n');
let successCount = 0;
let failCount = 0;
const startTime = Date.now();
// Process photos with progress display
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const progress = Math.round(((i + 1) / photos.length) * 100);
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
try {
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
if (result.success) {
successCount++;
} else {
failCount++;
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
}
} catch (error) {
failCount++;
console.log(`\n Error: ${photo.filename} - ${error.message}`);
}
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n');
console.log('='.repeat(60));
console.log('Watermark Generation Complete');
console.log('='.repeat(60));
console.log(` Total processed: ${photos.length}`);
console.log(` Successful: ${successCount}`);
console.log(` Failed: ${failCount}`);
console.log(` Duration: ${duration} seconds`);
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
if (failCount > 0) {
console.log('\nSome watermarks failed to generate. Check the errors above.');
console.log('You can re-run this script to retry failed photos.');
}
process.exit(failCount > 0 ? 1 : 0);
} catch (error) {
console.error('\nFatal error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const eventId = args[0] ? parseInt(args[0], 10) : null;
if (args[0] && isNaN(eventId)) {
console.error('Error: eventId must be a number');
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
process.exit(1);
}
// Run the script
generateWatermarks(eventId)
.then(() => {
process.exit(0);
})
.catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
+6 -4
View File
@@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) {
.where('id', admin.id)
.update({
password_hash: passwordHash,
must_change_password: true,
updated_at: new Date()
});
// Password logging removed for security - check logs or database if needed
console.log('Password: [NEWLY RESET - stored in database]');
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. Save this password securely - it will not be shown again');
console.log('2. You will be required to change it on next login');
} else {
console.log('Password: [hidden - use --reset flag to generate new password]');
}
+4 -3
View File
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
// Check if gallery has expired (only if expires_at is set)
// Galleries with null expires_at never expire
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
+37 -18
View File
@@ -25,14 +25,18 @@ const getEventFieldRequirements = async () => {
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
@@ -47,6 +51,8 @@ const getEventFieldRequirements = async () => {
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
@@ -55,7 +61,9 @@ const getEventFieldRequirements = async () => {
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
@@ -106,7 +114,7 @@ const hasCustomerContactColumns = async () => {
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('event_date').optional().isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
@@ -201,6 +209,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
@@ -245,10 +256,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
@@ -264,15 +278,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// Parse YYYY-MM-DD format as local date to avoid timezone issues
let expires_at;
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(event_date);
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -285,7 +304,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
slug,
event_type,
event_name,
event_date,
event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
@@ -295,7 +314,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
@@ -350,7 +369,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
status: 'pending',
@@ -367,7 +386,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
});
} catch (error) {
+22 -3
View File
@@ -12,6 +12,7 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const router = express.Router();
// Get storage path from environment or default
@@ -308,7 +309,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
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];
@@ -319,6 +320,14 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
} catch (thumbError) {
console.error(`Thumbnail generation 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];
watermarkGeneratorService.generateForPhoto(photoId)
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
}
// Add to successful uploads
uploadedPhotos.push({
@@ -461,7 +470,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
}
}
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
// Remove from database
await db('photos').where({ id: photoId }).delete();
@@ -582,8 +596,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
}
}
}
// Delete pre-generated watermark
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
}
// Delete from database
await db('photos')
.whereIn('id', photoIds)
+102 -22
View File
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -100,13 +102,20 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -127,17 +136,24 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
const settings = await db('app_settings')
.where('setting_type', type)
.select('*');
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
@@ -196,6 +212,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
hide_powered_by
} = req.body;
// Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash();
const brandingSettings = {
company_name,
company_tagline,
@@ -306,7 +325,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
res.json({ message: 'Branding settings updated successfully' });
// Check if watermark settings changed and trigger regeneration
const newSettingsHash = await watermarkService.getSettingsHash();
let watermarkRegenerationStarted = false;
if (oldSettingsHash !== newSettingsHash) {
// Clear watermark cache
watermarkService.clearCache();
// Check if watermarking is now enabled or settings changed
const currentSettings = await watermarkService.getWatermarkSettings();
if (currentSettings && currentSettings.enabled) {
// Start background regeneration of all watermarks
console.log('Watermark settings changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
} else {
// Watermarking was disabled, clear all pre-generated watermarks
console.log('Watermarking disabled, clearing pre-generated watermarks');
watermarkGeneratorService.clearAllWatermarks()
.catch(err => console.error('Failed to clear watermarks:', err));
}
}
res.json({
message: 'Branding settings updated successfully',
watermarkRegenerationStarted
});
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -392,11 +444,21 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
.first();
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
let oldPath;
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
// Try to parse as JSON first (for JSON-stringified paths)
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
oldPath = oldWatermarkLogoSetting.setting_value;
}
if (oldPath && typeof oldPath === 'string') {
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
}
}
}
@@ -421,19 +483,37 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_url',
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
updated_at: new Date()
});
res.json({
// Trigger watermark regeneration since the logo changed
watermarkService.clearCache();
const currentSettings = await watermarkService.getWatermarkSettings();
let watermarkRegenerationStarted = false;
if (currentSettings && currentSettings.enabled) {
console.log('Watermark logo changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
}
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
watermarkLogoUrl: publicPath,
watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
+32 -2
View File
@@ -7,6 +7,7 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const router = express.Router();
// Get system version
@@ -22,12 +23,15 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
} catch (err) {
console.error('Could not read package.json:', err);
}
const channel = getCurrentChannel(backendVersion);
res.json({
backend: backendVersion,
frontend: '1.0.0', // This will be set by frontend
node: process.version,
environment: process.env.NODE_ENV || 'production'
environment: process.env.NODE_ENV || 'production',
channel: channel
});
} catch (error) {
console.error('Error fetching version:', error);
@@ -35,6 +39,32 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
}
});
// Check for updates
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({
enabled: false,
message: 'Update checking is disabled'
});
}
const forceRefresh = req.query.refresh === 'true';
const updateInfo = await checkForUpdates(forceRefresh);
res.json({
enabled: true,
...updateInfo
});
} catch (error) {
logger.error('Error checking for updates:', error);
res.status(500).json({ error: 'Failed to check for updates' });
}
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
+102 -27
View File
@@ -5,6 +5,7 @@ const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -12,6 +13,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail } = require('../services/imageProcessor');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
@@ -150,7 +152,7 @@ router.get('/:slug/info', async (req, res) => {
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
@@ -171,7 +173,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// First get all photos
let photos = await db('photos')
.where('photos.event_id', req.event.id)
@@ -326,15 +334,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
// Add watermark version to URLs for cache busting when settings change
const wmQuery = wmVersion ? `?${wmVersion}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -753,13 +763,54 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const fs = require('fs');
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
// Try to serve pre-generated watermarked file for instant loading
if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try {
const fs = require('fs');
// Check if pre-generated watermark file exists
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
return res.sendFile(watermarkFilePath);
}
} catch (err) {
// File doesn't exist or error, fall through to on-the-fly generation
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
}
}
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
// Also queue regeneration for next time
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id)
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -768,6 +819,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -787,30 +839,30 @@ router.get('/:slug/photo/:photoId',
);
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
const thumbPath = path.join(getStoragePath(), thumbnailPath);
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
@@ -818,18 +870,41 @@ router.get('/:slug/thumbnail/:photoId',
req.clientInfo,
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
const fs = require('fs');
const stat = fs.statSync(thumbPath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true'
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
// Send file
res.sendFile(path.resolve(thumbPath));
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
res.send(watermarkedBuffer);
} else {
// Send file without watermark
res.sendFile(path.resolve(thumbPath));
}
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
+36 -24
View File
@@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
}, 'view');
// Get protection settings from event
const eventProtectionLevel = req.event.protection_level || protectionLevel;
const protectionSettings = {
protectionLevel: req.event.protection_level || protectionLevel,
protectionLevel: eventProtectionLevel,
quality: req.event.image_quality || 85,
addFingerprint: req.event.add_fingerprint !== false,
fragmentImage: protectionLevel === 'maximum'
fragmentImage: eventProtectionLevel === 'maximum'
};
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
// Process image with protection
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
// Apply watermark if enabled
// For basic/standard protection without special features, serve original file
// This avoids unnecessary recompression
const needsProcessing = eventProtectionLevel === 'enhanced' ||
eventProtectionLevel === 'maximum' ||
protectionSettings.addFingerprint;
let finalImage;
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
if (!needsProcessing) {
// Serve original file without processing
const fs = require('fs').promises;
finalImage = await fs.readFile(photoPath);
} else {
const watermarkSettings = await watermarkService.getWatermarkSettings();
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Process image with protection measures
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
}
finalImage = processedImage;
}
// Set security headers
+3 -1
View File
@@ -74,7 +74,9 @@ router.get('/', async (req, res) => {
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false
event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== false
};
res.json(publicSettings);
@@ -21,9 +21,11 @@ async function checkExpirations() {
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -40,9 +42,11 @@ async function checkExpirations() {
}
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
+64 -7
View File
@@ -157,6 +157,8 @@ class SecureImageService {
/**
* Process image with protection measures
* For basic/standard protection without fingerprinting, returns original file
* For enhanced/maximum protection, applies quality reduction and fingerprinting
*/
async processProtectedImage(imagePath, options = {}) {
const {
@@ -169,11 +171,58 @@ class SecureImageService {
} = options;
try {
// For basic protection level, always return original file without processing
if (protectionLevel === 'basic') {
return await fs.readFile(imagePath);
}
// For standard protection without fingerprinting, return original file
// This avoids unnecessary recompression when no protection features are needed
if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) {
return await fs.readFile(imagePath);
}
// Get metadata to check if processing is actually needed
const metadata = await sharp(imagePath).metadata();
// For standard protection with fingerprint only (no resize needed, no quality change),
// we can add fingerprint without full recompression by preserving format
const needsResize = metadata.width > maxWidth || metadata.height > maxHeight;
const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum';
// If standard protection and only fingerprinting is needed, and image doesn't need resize,
// just add metadata without recompressing
if (protectionLevel === 'standard' && addFingerprint && !needsResize) {
let image = sharp(imagePath);
// Add fingerprint to metadata without changing image quality
const fingerprint = crypto.randomBytes(16).toString('hex');
// Preserve original format with high quality
const format = metadata.format || 'jpeg';
if (format === 'png') {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: 95 });
} else {
image = image.jpeg({ quality: 100, mozjpeg: true });
}
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
});
return await image.toBuffer();
}
// For enhanced/maximum protection or when resize is needed, do full processing
let image = sharp(imagePath);
const metadata = await image.metadata();
let effectiveQuality = quality;
// Resize if too large
if (metadata.width > maxWidth || metadata.height > maxHeight) {
if (needsResize) {
image = image.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
@@ -182,18 +231,26 @@ class SecureImageService {
// Apply quality reduction for protection
if (protectionLevel === 'enhanced') {
quality = Math.min(quality, 70);
effectiveQuality = Math.min(quality, 70);
} else if (protectionLevel === 'maximum') {
quality = Math.min(quality, 60);
effectiveQuality = Math.min(quality, 60);
}
// Convert to appropriate format
image = image.jpeg({ quality, progressive: true });
// Preserve original format when possible, apply quality settings
const format = metadata.format || 'jpeg';
if (format === 'png' && !needsQualityReduction) {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: effectiveQuality });
} else {
// JPEG or when quality reduction is needed (convert to JPEG)
image = image.jpeg({ quality: effectiveQuality, progressive: true });
}
// Add invisible watermark/fingerprint
if (addFingerprint) {
const fingerprint = crypto.randomBytes(16).toString('hex');
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
+212
View File
@@ -0,0 +1,212 @@
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
// Cache for version info (avoid hitting GitHub API too often)
let versionCache = null;
let lastCheck = 0;
const CACHE_TTL = 60 * 60 * 1000; // 1 hour cache
/**
* Get current installed version from package.json
*/
async function getCurrentVersion() {
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = await fs.readFile(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
return packageJson.version || '0.0.0';
} catch (err) {
logger.error('Could not read package.json for version:', err);
return '0.0.0';
}
}
/**
* Determine current release channel from version or environment
*/
function getCurrentChannel(version) {
// Check environment variable first
const envChannel = process.env.PICPEAK_RELEASE_CHANNEL;
if (envChannel && ['stable', 'beta'].includes(envChannel)) {
return envChannel;
}
// Infer from version string
if (version && version.includes('-beta')) {
return 'beta';
}
return 'stable';
}
/**
* Parse version string into comparable parts
*/
function parseVersion(version) {
if (!version) return null;
// Handle versions like "2.3.0" or "2.3.0-beta.1"
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
beta: match[4] ? parseInt(match[4], 10) : null,
isBeta: !!match[4]
};
}
/**
* Compare two versions
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
function compareVersions(a, b) {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return 0;
// Compare major.minor.patch
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
// Handle beta vs stable
if (va.isBeta && !vb.isBeta) return -1; // beta < stable
if (!va.isBeta && vb.isBeta) return 1; // stable > beta
// Both are beta - compare beta numbers
if (va.isBeta && vb.isBeta) {
if (va.beta !== vb.beta) return va.beta > vb.beta ? 1 : -1;
}
return 0;
}
/**
* Fetch available versions from GitHub Releases
* Uses GitHub Releases API which is publicly accessible without authentication
*/
async function fetchAvailableVersions() {
try {
// Use GitHub Releases API (public, no auth required)
const response = await axios.get(
'https://api.github.com/repos/the-luap/picpeak/releases',
{
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'PicPeak-Update-Checker'
},
timeout: 10000
}
);
// Extract version tags from releases
const versions = {
stable: [],
beta: []
};
for (const release of response.data) {
const tag = release.tag_name;
if (!tag) continue;
// Remove 'v' prefix if present
const version = tag.startsWith('v') ? tag.substring(1) : tag;
if (version.match(/^\d+\.\d+\.\d+$/)) {
// Stable version
versions.stable.push(version);
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
// Beta version
versions.beta.push(version);
}
}
// Sort versions descending (newest first)
versions.stable.sort((a, b) => compareVersions(b, a));
versions.beta.sort((a, b) => compareVersions(b, a));
return versions;
} catch (error) {
logger.error('Failed to fetch available versions from GitHub:', error.message);
return null;
}
}
/**
* Check for available updates
*/
async function checkForUpdates(forceRefresh = false) {
const now = Date.now();
// Use cache if available and not expired
if (!forceRefresh && versionCache && (now - lastCheck) < CACHE_TTL) {
return versionCache;
}
const currentVersion = await getCurrentVersion();
const currentChannel = getCurrentChannel(currentVersion);
const availableVersions = await fetchAvailableVersions();
if (!availableVersions) {
return {
current: currentVersion,
channel: currentChannel,
updateAvailable: false,
error: 'Unable to check for updates'
};
}
// Determine latest version for current channel
const latestStable = availableVersions.stable[0] || currentVersion;
const latestBeta = availableVersions.beta[0] || currentVersion;
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
// Also check if there's a newer beta for stable users who want to preview
const newerBetaAvailable = currentChannel === 'stable' &&
availableVersions.beta.length > 0 &&
compareVersions(latestBeta, currentVersion) > 0;
const result = {
current: currentVersion,
channel: currentChannel,
latest: {
stable: latestStable,
beta: latestBeta,
forChannel: latestForChannel
},
updateAvailable,
newerBetaAvailable,
lastChecked: new Date().toISOString()
};
// Update cache
versionCache = result;
lastCheck = now;
return result;
}
/**
* Clear the version cache (useful for testing)
*/
function clearCache() {
versionCache = null;
lastCheck = 0;
}
module.exports = {
checkForUpdates,
getCurrentVersion,
getCurrentChannel,
compareVersions,
parseVersion,
clearCache
};
@@ -0,0 +1,433 @@
/**
* WatermarkGeneratorService
*
* Handles batch generation of pre-watermarked images for fast serving.
* This service is responsible for:
* - Generating watermarks for newly uploaded photos
* - Regenerating all watermarks when settings change
* - Tracking regeneration progress
*/
const path = require('path');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { getStoragePath } = require('../config/storage');
class WatermarkGeneratorService {
constructor() {
// Track active regeneration jobs
this.activeJobs = new Map();
// Batch size for processing (to manage memory)
this.batchSize = 10;
// Concurrent processing limit
this.concurrentLimit = 2;
}
/**
* Generate watermark for a single photo
* @param {number} photoId - The photo ID
* @returns {Object} Result with success status and watermark path
*/
async generateForPhoto(photoId) {
try {
// Get photo with event info
const photo = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.id', photoId)
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
)
.first();
if (!photo) {
return { success: false, error: 'Photo not found' };
}
// Skip video files
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
return { success: false, error: 'Videos do not support watermarks' };
}
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { success: false, error: 'Watermarking is disabled' };
}
// Resolve the original file path
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, error: 'Could not resolve photo path' };
}
// Generate and save watermark
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
// Update database with watermark path
await db('photos')
.where({ id: photoId })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return result;
} catch (error) {
console.error(`Error generating watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Resolve the full file path for a photo
*/
resolvePhotoPath(photo) {
const storagePath = getStoragePath();
// Handle external/reference mode
if (photo.source_mode === 'reference' && photo.external_relpath) {
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
}
// Standard managed mode
if (photo.file_path) {
// file_path might be absolute or relative
if (path.isAbsolute(photo.file_path)) {
return photo.file_path;
}
return path.join(storagePath, photo.file_path);
}
// Fallback to constructing path from slug and filename
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
}
/**
* Generate watermarks for all photos in an event
* @param {number} eventId - The event ID
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async generateForEvent(eventId, onProgress = null) {
const results = { total: 0, success: 0, failed: 0, errors: [] };
try {
// Get all photos for the event (excluding videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.event_id', eventId)
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
return results;
}
// Get watermark settings once
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { ...results, errors: ['Watermarking is disabled'] };
}
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Progress callback
if (onProgress) {
onProgress({
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed
});
}
}
return results;
} catch (error) {
console.error(`Error generating watermarks for event ${eventId}:`, error);
return { ...results, errors: [...results.errors, error.message] };
}
}
/**
* Process watermark for a single photo (internal helper)
*/
async processPhotoWatermark(photo, settings) {
try {
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
}
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
await db('photos')
.where({ id: photo.id })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return { ...result, photoId: photo.id };
} catch (error) {
return { success: false, photoId: photo.id, error: error.message };
}
}
/**
* Regenerate watermarks for all photos in the system
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async regenerateAll(onProgress = null) {
const jobId = Date.now().toString();
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
try {
this.activeJobs.set(jobId, results);
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
results.status = 'completed';
results.errors.push('Watermarking is disabled');
return results;
}
// First, clear existing watermarks from DB (the files will be overwritten)
// This ensures stale paths don't persist if regeneration fails
// Get all image photos (exclude videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
results.status = 'completed';
return results;
}
console.log(`Starting watermark regeneration for ${photos.length} photos`);
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
// Check if job was cancelled
if (!this.activeJobs.has(jobId)) {
results.status = 'cancelled';
return results;
}
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error && results.errors.length < 50) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Update job status
this.activeJobs.set(jobId, { ...results });
// Progress callback
if (onProgress) {
onProgress({
jobId,
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed,
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
});
}
// Small delay between batches to prevent CPU saturation
await new Promise(resolve => setTimeout(resolve, 100));
}
results.status = 'completed';
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
return results;
} catch (error) {
console.error('Error during watermark regeneration:', error);
results.status = 'failed';
results.errors.push(error.message);
return results;
} finally {
// Clean up job tracking after a delay
setTimeout(() => {
this.activeJobs.delete(jobId);
}, 60000); // Keep for 1 minute for status queries
}
}
/**
* Clear all watermarks (when watermarking is disabled)
*/
async clearAllWatermarks() {
try {
// Get all photos with watermarks
const photos = await db('photos')
.whereNotNull('watermark_path')
.select('id', 'watermark_path');
// Delete watermark files
for (const photo of photos) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
}
// Clear database paths
await db('photos')
.whereNotNull('watermark_path')
.update({
watermark_path: null,
watermark_generated_at: null
});
console.log(`Cleared ${photos.length} watermarks`);
return { success: true, cleared: photos.length };
} catch (error) {
console.error('Error clearing watermarks:', error);
return { success: false, error: error.message };
}
}
/**
* Delete watermark for a specific photo
*/
async deleteForPhoto(photoId) {
try {
const photo = await db('photos')
.where({ id: photoId })
.select('watermark_path')
.first();
if (photo && photo.watermark_path) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
await db('photos')
.where({ id: photoId })
.update({
watermark_path: null,
watermark_generated_at: null
});
}
return { success: true };
} catch (error) {
console.error(`Error deleting watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Get status of an active regeneration job
*/
getJobStatus(jobId) {
return this.activeJobs.get(jobId) || null;
}
/**
* Cancel an active regeneration job
*/
cancelJob(jobId) {
if (this.activeJobs.has(jobId)) {
this.activeJobs.delete(jobId);
return true;
}
return false;
}
/**
* Check if there's an active regeneration job
*/
hasActiveJob() {
for (const [, job] of this.activeJobs) {
if (job.status === 'running') {
return true;
}
}
return false;
}
/**
* Get count of photos needing watermark generation
*/
async getPendingCount() {
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return 0;
}
const result = await db('photos')
.whereNull('watermark_path')
.whereNot(function() {
this.where('media_type', 'video')
.orWhere('mime_type', 'like', 'video/%');
})
.count('id as count')
.first();
return parseInt(result.count) || 0;
}
}
module.exports = new WatermarkGeneratorService();
+139 -8
View File
@@ -2,6 +2,7 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { getStoragePath } = require('../config/storage');
class WatermarkService {
constructor() {
@@ -177,14 +178,25 @@ class WatermarkService {
settings.position
);
// Apply watermark
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.toBuffer();
// Apply watermark with high quality output to preserve original image quality
let watermarkedImage = image.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}]);
// Preserve original format with high quality settings
const format = metadata.format || 'jpeg';
let watermarkedBuffer;
if (format === 'png') {
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
} else if (format === 'webp') {
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
} else {
// Default to JPEG with maximum quality (100) to prevent recompression
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
}
// Cache the result
this.cache.set(cacheKey, {
@@ -221,6 +233,125 @@ class WatermarkService {
clearCache() {
this.cache.clear();
}
/**
* Get the watermarks directory path, creating it if needed
*/
async getWatermarksDir() {
const watermarksDir = path.join(getStoragePath(), 'watermarks');
try {
await fs.access(watermarksDir);
} catch {
await fs.mkdir(watermarksDir, { recursive: true });
}
return watermarksDir;
}
/**
* Get the file extension from a filename
*/
getFileExtension(filename) {
const ext = path.extname(filename).toLowerCase();
// Map common extensions
if (ext === '.jpeg') return '.jpg';
return ext || '.jpg';
}
/**
* Generate watermarked version of a photo and save to disk
* @param {Object} photo - Photo object with id, filename, and path info
* @param {string} originalPath - Full path to the original image file
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
* @returns {Object} { success, watermarkPath, error }
*/
async generateAndSaveWatermark(photo, originalPath, settings = null) {
try {
// Get settings if not provided
if (!settings) {
settings = await this.getWatermarkSettings();
}
// If watermarking is disabled, return early
if (!settings || !settings.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
// Verify original file exists
try {
await fs.access(originalPath);
} catch {
return { success: false, watermarkPath: null, error: 'Original file not found' };
}
// Generate watermarked buffer using existing method
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
// Determine output path
const watermarksDir = await this.getWatermarksDir();
const ext = this.getFileExtension(photo.filename);
const outputFilename = `${photo.id}_watermarked${ext}`;
const outputPath = path.join(watermarksDir, outputFilename);
// Write the watermarked image to disk
await fs.writeFile(outputPath, watermarkedBuffer);
// Return relative path for database storage
const relativePath = `watermarks/${outputFilename}`;
return {
success: true,
watermarkPath: relativePath,
error: null
};
} catch (error) {
console.error(`Error generating watermark for photo ${photo.id}:`, error);
return {
success: false,
watermarkPath: null,
error: error.message
};
}
}
/**
* Delete a pre-generated watermark file
* @param {string} watermarkPath - Relative path to the watermark file
* @returns {boolean} - True if deleted successfully
*/
async deleteWatermarkFile(watermarkPath) {
if (!watermarkPath) return false;
try {
const fullPath = path.join(getStoragePath(), watermarkPath);
await fs.unlink(fullPath);
return true;
} catch (error) {
// File might not exist, which is fine
if (error.code !== 'ENOENT') {
console.error('Error deleting watermark file:', error);
}
return false;
}
}
/**
* Create a hash of current watermark settings for change detection
* @returns {string} - Hash string of settings
*/
async getSettingsHash() {
const settings = await this.getWatermarkSettings();
if (!settings) return '';
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
// Simple hash for change detection (not cryptographic)
let hash = 0;
for (let i = 0; i < hashData.length; i++) {
const char = hashData.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
}
}
module.exports = new WatermarkService();
+5 -2
View File
@@ -38,7 +38,8 @@ services:
backend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/backend:latest
# PICPEAK_CHANNEL: 'stable' (default), 'beta', or specific version like 'v2.3.0'
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
container_name: picpeak-backend
env_file: .env
environment:
@@ -46,6 +47,7 @@ services:
- DB_HOST=${DB_HOST:-postgres}
- REDIS_HOST=redis
- PHOTOS_DIR=/app/storage/events
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
volumes:
- ${APP_STORAGE}:/app/storage
- ${LOGS}:/app/logs
@@ -69,7 +71,8 @@ services:
frontend:
# Use pre-built image from GitHub Container Registry
image: ghcr.io/the-luap/picpeak/frontend:latest
# Uses same channel as backend for consistency
image: ghcr.io/the-luap/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
container_name: picpeak-frontend
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.2.3",
"version": "3.2.1-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowUpCircle, X, ExternalLink } from 'lucide-react';
import { api } from '../../config/api';
interface UpdateInfo {
enabled: boolean;
current: string;
channel: 'stable' | 'beta';
latest: {
stable: string;
beta: string;
forChannel: string;
};
updateAvailable: boolean;
newerBetaAvailable?: boolean;
lastChecked: string;
error?: string;
message?: string;
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
interface UpdateNotificationProps {
onDismiss?: () => void;
}
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false,
refetchOnWindowFocus: false
});
// Don't render if no update available, not enabled, or dismissed
if (!updateInfo?.enabled || !updateInfo?.updateAvailable || dismissed) {
return null;
}
const handleDismiss = () => {
setDismissed(true);
onDismiss?.();
};
const channelLabel = updateInfo.channel === 'beta'
? t('admin.updates.channelBeta', 'Beta')
: t('admin.updates.channelStable', 'Stable');
return (
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
<div className="flex items-start justify-between">
<div className="flex items-start">
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h4 className="text-sm font-semibold text-blue-800">
{t('admin.updates.available', 'Update Available')}
</h4>
<p className="text-sm text-blue-700 mt-1">
{t('admin.updates.newVersion', 'Version {{version}} is available', {
version: updateInfo.latest.forChannel
})}
<span className="text-blue-500 ml-2">
({t('admin.updates.currentVersion', 'Current: {{version}}', {
version: updateInfo.current
})})
</span>
</p>
<p className="text-xs text-blue-600 mt-1">
{t('admin.updates.channel', 'Channel: {{channel}}', {
channel: channelLabel
})}
</p>
<a
href="https://github.com/the-luap/picpeak/releases"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mt-2"
>
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
<button
onClick={handleDismiss}
className="text-blue-400 hover:text-blue-600 p-1"
aria-label={t('common.close', 'Close')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
};
+39 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { Info, ArrowUpCircle } from 'lucide-react';
import { api } from '../../config/api';
import packageJson from '../../../package.json';
@@ -13,6 +13,15 @@ interface SystemVersion {
frontend: string;
node: string;
environment: string;
channel?: 'stable' | 'beta';
}
interface UpdateInfo {
enabled: boolean;
updateAvailable: boolean;
latest?: {
forChannel: string;
};
}
async function fetchSystemVersion(): Promise<SystemVersion> {
@@ -20,6 +29,11 @@ async function fetchSystemVersion(): Promise<SystemVersion> {
return response.data;
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
export const VersionInfo: React.FC = () => {
const { t } = useTranslation();
const { data: versionInfo } = useQuery({
@@ -28,11 +42,25 @@ export const VersionInfo: React.FC = () => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false
});
const channelBadge = versionInfo?.channel === 'beta' ? (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">
{t('admin.updates.beta', 'BETA')}
</span>
) : null;
return (
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
{channelBadge}
</div>
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
<div>Frontend: v{FRONTEND_VERSION}</div>
@@ -40,6 +68,16 @@ export const VersionInfo: React.FC = () => {
<div>Backend: v{versionInfo.backend}</div>
)}
</div>
{updateInfo?.enabled && updateInfo?.updateAvailable && (
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600">
<ArrowUpCircle className="w-3 h-3" />
<span>
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
version: updateInfo.latest?.forChannel
})}
</span>
</div>
)}
</div>
);
};
@@ -7,7 +7,7 @@ import {
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
src: string;
fallbackSrc?: string;
useWatermark?: boolean;
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
detectDevTools?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
onLoad?: () => void;
}
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
detectDevTools,
protectionLevel,
useEnhancedProtection,
onLoad,
...props
}) => {
const unusedProps = {
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = () => {
imageRef.current = img;
drawToCanvas();
onLoad?.();
};
img.onerror = (e) => {
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
img.onload = null;
img.onerror = null;
};
}, [imageSrc, useCanvasRendering, drawToCanvas]);
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
if (isLoading) {
return (
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
);
}
return <img src={imageSrc} alt={alt} {...props} />;
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
};
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
@@ -19,6 +21,7 @@ export const DynamicFavicon: React.FC = () => {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Update favicon when branding settings change
useEffect(() => {
if (settings?.branding_favicon_url) {
// Remove existing favicon links
@@ -29,13 +32,27 @@ export const DynamicFavicon: React.FC = () => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
document.head.appendChild(link);
}
}, [settings?.branding_favicon_url]);
// Update document title when company name or tagline changes
useEffect(() => {
const companyName = settings?.branding_company_name?.trim();
const tagline = settings?.branding_company_tagline?.trim();
if (companyName && tagline) {
document.title = `${companyName} - ${tagline}`;
} else if (companyName) {
document.title = companyName;
} else {
document.title = DEFAULT_TITLE;
}
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
};
@@ -240,11 +240,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Right side - Action buttons */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* Extra header items (upload button, etc.) */}
{headerExtra && (
<div className="hidden sm:block">
{headerExtra}
</div>
)}
{headerExtra}
{/* Download all button - hidden on mobile when sidebar is shown */}
{showDownloadAll && onDownloadAll && (
@@ -366,7 +362,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoSize.style || {}),
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandingSettings?.logo_url
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
@@ -441,7 +440,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
<p className="text-xs sm:text-sm text-neutral-500">
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -139,18 +139,18 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Content */}
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
{/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200">
{/* Upload Section - Show prominently at top for mobile users */}
{allowUploads && onUploadClick && (
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-neutral-200">
<Button
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => {
onUploadClick();
onClose();
if (isMobile) onClose();
}}
className="w-full"
className="gallery-btn w-full"
>
{t('upload.uploadPhotos')}
</Button>
@@ -203,7 +203,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_size: settingsData.branding_logo_size || 'medium',
@@ -380,12 +380,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
});
// Transform URLs for watermarks if enabled
// Transform full-size URLs for watermarks if enabled
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/gallery/${slug}/photo/${photo.id}`,
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
@@ -608,9 +608,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
// Upload button only on desktop when sidebar is shown
// Upload button for sidebar layouts (shown on both mobile and desktop)
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
if (allowUploads && showSidebar && !isMobile) {
if (allowUploads && showSidebar) {
items.push(
<Button
key="upload-button"
@@ -619,7 +619,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
>
{t('upload.uploadPhotos')}
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
</Button>
);
}
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Reset image loaded state when changing photos
useEffect(() => {
setImageLoaded(false);
}, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
opacity: imageLoaded ? 1 : 0,
}}
draggable={false}
onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
@@ -142,8 +142,11 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
}
alt="Event logo"
className="h-20 sm:h-24 lg:h-32 mx-auto"
style={{
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
style={{
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: eventLogo
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
}}
/>
</div>
@@ -49,6 +49,8 @@ export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
}
export function useSettingsState() {
@@ -109,7 +111,9 @@ export function useSettingsState() {
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
});
// Account form state
@@ -178,7 +182,9 @@ export function useSettingsState() {
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
});
}
}, [settings, i18n]);
@@ -99,6 +99,56 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_event_date}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_event_date: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireEventDate', 'Require event date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
</p>
{!eventSettings.event_require_event_date && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.eventDateWarning', 'Gallery URLs will use random identifiers instead of dates')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_expiration}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_expiration: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireExpiration', 'Require expiration date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
</p>
{!eventSettings.event_require_expiration && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.expirationWarning', 'Galleries without expiration will remain active until manually archived')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
+93 -1
View File
@@ -759,6 +759,8 @@
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
"noExpiration": "Kein Ablaufdatum",
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
"userUploads": "Benutzer-Upload-Einstellungen",
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
@@ -778,6 +780,10 @@
"enableDevtoolsProtection": "Entwicklertools erkennen",
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
"protectionLevelBasic": "Einfach - Nur Rechtsklick-Sperre",
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
"heroPhoto": "Hero-Foto",
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
"selectHeroPhoto": "Hero-Foto auswählen",
@@ -1032,6 +1038,54 @@
"saveSettings": "Sicherheitseinstellungen speichern",
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
},
"events": {
"title": "Veranstaltungserstellung",
"requiredFields": "Pflichtfelder",
"requiredFieldsDescription": "Konfigurieren Sie, welche Kontaktfelder beim Erstellen neuer Veranstaltungen erforderlich sind.",
"requireCustomerName": "Kundenname erforderlich",
"requireCustomerNameHelp": "Kundenname muss für neue Veranstaltungen angegeben werden",
"requireCustomerEmail": "Kunden-E-Mail erforderlich",
"requireCustomerEmailHelp": "Kunden-E-Mail muss für neue Veranstaltungen angegeben werden",
"customerEmailWarning": "Erforderlich für das Versenden von Galerie-Einladungen",
"requireAdminEmail": "Admin-E-Mail erforderlich",
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
"requireEventDate": "Veranstaltungsdatum erforderlich",
"requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden",
"eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten",
"requireExpiration": "Ablaufdatum erforderlich",
"requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben",
"expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden",
"saveSettings": "Veranstaltungseinstellungen speichern",
"noteTitle": "Hinweis",
"noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
},
"imageSecurity": {
"title": "Bildschutz",
"saveSuccess": "Bildschutz-Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"loadError": "Fehler beim Laden der Bildschutz-Einstellungen",
"defaultProtection": "Standard-Schutzeinstellungen",
"defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
"protectionLevel": "Standard-Schutzstufe",
"imageQuality": "Standard-Bildqualität",
"fragmentationLevel": "Fragmentierungsstufe",
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
"enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
"rateLimiting": "Ratenbegrenzung",
"rateLimitingHelp": "Begrenzen Sie, wie viele Bilder angefordert werden können, um Scraping zu verhindern.",
"requestsPerMinute": "Anfragen pro Minute",
"requestsPer5Minutes": "Anfragen pro 5 Min.",
"requestsPerHour": "Anfragen pro Stunde",
"securityMonitoring": "Sicherheitsüberwachung",
"suspiciousThreshold": "Schwellenwert für verdächtige Aktivitäten",
"autoBlockThreshold": "Automatische Sperrschwelle",
"enableMonitoring": "Sicherheitsüberwachung aktivieren",
"blockSuspiciousIps": "Verdächtige IPs automatisch blockieren",
"logEvents": "Sicherheitsereignisse in Datenbank protokollieren",
"infoTitle": "Über Bildschutz",
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
},
"categories": {
"title": "Kategorien",
"about": "Über Fotokategorien",
@@ -1209,7 +1263,31 @@
"customizeTheme": "Design anpassen",
"saveTheme": "Design speichern",
"previewLayout": "Vorschau-Layout",
"livePreview": "Live-Vorschau"
"livePreview": "Live-Vorschau",
"whiteLabel": "White Label",
"hidePoweredBy": "\"Powered by PicPeak\" Branding ausblenden",
"hidePoweredByHelp": "Entfernen Sie die PicPeak-Kennzeichnung aus Galerie-Fußzeilen für ein vollständig personalisiertes Erscheinungsbild",
"logoCustomization": "Logo-Anpassung",
"changeLogo": "Logo ändern",
"logoSizeSmall": "Klein (32px)",
"logoSizeMedium": "Mittel (48px)",
"logoSizeLarge": "Groß (64px)",
"logoSizeXLarge": "Extra Groß (96px)",
"logoSizeCustom": "Benutzerdefiniert",
"logoMaxHeight": "Maximale Höhe (Pixel)",
"logoMaxHeightHelp": "Legen Sie eine benutzerdefinierte maximale Höhe für das Logo fest (20-200 Pixel)",
"logoPosition": "Logo-Position im Header",
"positionLeft": "Links",
"positionCenter": "Mitte",
"positionRight": "Rechts",
"logoDisplayMode": "Anzeigemodus",
"logoOnly": "Nur Logo",
"textOnly": "Nur Firmenname",
"logoAndText": "Logo und Firmenname",
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
"showLogoInHero": "Logo im Hero-Bereich anzeigen",
"showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)"
},
"admin": {
"title": "Admin-Panel",
@@ -1232,6 +1310,20 @@
"error": "Fehler",
"checking": "Prüfe..."
},
"updates": {
"available": "Update verfügbar",
"newVersion": "Version {{version}} ist verfügbar",
"currentVersion": "Aktuell: {{version}}",
"channel": "Kanal: {{channel}}",
"channelStable": "Stabil",
"channelBeta": "Beta",
"beta": "BETA",
"viewReleaseNotes": "Versionshinweise anzeigen",
"updateAvailableShort": "v{{version}} verfügbar",
"checkForUpdates": "Nach Updates suchen",
"upToDate": "Alles aktuell",
"lastChecked": "Zuletzt geprüft: {{time}}"
},
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
+51 -1
View File
@@ -433,6 +433,8 @@
"expiresOn": "Expires on",
"galleryWillExpireOn": "Gallery will expire on {{date}}",
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
"noExpiration": "No Expiration",
"noExpirationHelp": "This gallery will remain active until manually archived.",
"userUploads": "User Upload Settings",
"allowUserUploads": "Allow guests to upload photos",
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
@@ -452,6 +454,10 @@
"enableDevtoolsProtection": "Detect developer tools",
"useCanvasRendering": "Canvas rendering (advanced protection)",
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
"protectionLevelBasic": "Basic - Right-click blocking only",
"protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
"protectionLevelEnhanced": "Enhanced - Print screen detection",
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
"heroPhoto": "Hero Photo",
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
"selectHeroPhoto": "Select Hero Photo",
@@ -749,6 +755,12 @@
"requireAdminEmail": "Require admin email",
"requireAdminEmailHelp": "Admin email must be provided for new events",
"adminEmailWarning": "Required for receiving event notifications",
"requireEventDate": "Require event date",
"requireEventDateHelp": "Event date must be provided when creating events",
"eventDateWarning": "Gallery URLs will use random identifiers instead of dates",
"requireExpiration": "Require expiration date",
"requireExpirationHelp": "Galleries must have an expiration date",
"expirationWarning": "Galleries without expiration will remain active until manually archived",
"saveSettings": "Save Event Settings",
"noteTitle": "Note",
"noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields."
@@ -1014,7 +1026,31 @@
"customizeTheme": "Customize Theme",
"saveTheme": "Save Theme",
"previewLayout": "Preview Layout",
"livePreview": "Live Preview"
"livePreview": "Live Preview",
"whiteLabel": "White Label",
"hidePoweredBy": "Hide \"Powered by PicPeak\" branding",
"hidePoweredByHelp": "Remove the PicPeak attribution from gallery footers for a fully white-labeled experience",
"logoCustomization": "Logo Customization",
"changeLogo": "Change Logo",
"logoSizeSmall": "Small (32px)",
"logoSizeMedium": "Medium (48px)",
"logoSizeLarge": "Large (64px)",
"logoSizeXLarge": "Extra Large (96px)",
"logoSizeCustom": "Custom",
"logoMaxHeight": "Maximum Height (pixels)",
"logoMaxHeightHelp": "Set a custom maximum height for the logo (20-200 pixels)",
"logoPosition": "Logo Position in Header",
"positionLeft": "Left",
"positionCenter": "Center",
"positionRight": "Right",
"logoDisplayMode": "Display Mode",
"logoOnly": "Logo Only",
"textOnly": "Company Name Only",
"logoAndText": "Logo and Company Name",
"showLogoInHeader": "Show logo in gallery header",
"showLogoInHeaderHelp": "Display the logo in the main header bar",
"showLogoInHero": "Show logo in hero section",
"showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)"
},
"admin": {
"title": "Admin Panel",
@@ -1037,6 +1073,20 @@
"error": "Error",
"checking": "Checking..."
},
"updates": {
"available": "Update Available",
"newVersion": "Version {{version}} is available",
"currentVersion": "Current: {{version}}",
"channel": "Channel: {{channel}}",
"channelStable": "Stable",
"channelBeta": "Beta",
"beta": "BETA",
"viewReleaseNotes": "View Release Notes",
"updateAvailableShort": "v{{version}} available",
"checkForUpdates": "Check for Updates",
"upToDate": "You're up to date",
"lastChecked": "Last checked: {{time}}"
},
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Loading } from '../../components/common';
import { UpdateNotification } from '../../components/admin/UpdateNotification';
import { useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { adminService } from '../../services/admin.service';
@@ -140,6 +141,9 @@ export const AdminDashboard: React.FC = () => {
return (
<div>
{/* Update Notification */}
<UpdateNotification />
{/* Page Header */}
<div className="flex justify-between items-center mb-8">
<div>
+2 -2
View File
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
company_name: '',
company_tagline: '',
footer_text: '© 2024 Your Company. All rights reserved.',
footer_text: '',
support_email: '',
watermark_enabled: false,
watermark_position: 'bottom-right',
@@ -307,7 +307,7 @@ export const BrandingPage: React.FC = () => {
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
rows={2}
placeholder="© 2024 Your Company. All rights reserved."
placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
/>
</div>
</div>
+40 -26
View File
@@ -148,6 +148,8 @@ export const CreateEventPage: React.FC = () => {
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const requireEventDate = publicSettings?.event_require_event_date !== false;
const requireExpiration = publicSettings?.event_require_expiration !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
@@ -201,7 +203,7 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.event_date) {
if (requireEventDate && !formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
@@ -247,7 +249,7 @@ export const CreateEventPage: React.FC = () => {
}
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) {
newErrors.expires_in_days = t('validation.expirationRange');
}
@@ -267,7 +269,7 @@ export const CreateEventPage: React.FC = () => {
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
event_date: formData.event_date || undefined,
customer_name: formData.customer_name,
customer_email: formData.customer_email,
admin_email: formData.admin_email,
@@ -275,7 +277,7 @@ export const CreateEventPage: React.FC = () => {
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
expiration_days: requireExpiration ? formData.expires_in_days : undefined,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id,
@@ -394,7 +396,7 @@ export const CreateEventPage: React.FC = () => {
<Input
type="date"
label={t('events.eventDate')}
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
@@ -666,30 +668,42 @@ export const CreateEventPage: React.FC = () => {
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
{requireExpiration ? (
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
)}
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
) : (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2 text-blue-800">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
</div>
<p className="mt-1 text-xs text-blue-700">
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
</p>
)}
</div>
</div>
)}
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
+1 -1
View File
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
</Link>
</div>
<p className="text-sm text-neutral-500 mt-4">
© 2024 PicPeak. All rights reserved.
© {new Date().getFullYear()} PicPeak. All rights reserved.
</p>
</div>
</footer>
+4 -4
View File
@@ -20,15 +20,15 @@ const normalizeEvent = (event: Event): Event => {
interface CreateEventData {
event_type: string;
event_name: string;
event_date: string;
event_date?: string;
customer_name?: string;
customer_email: string;
admin_email: string;
customer_email?: string;
admin_email?: string;
require_password?: boolean;
password?: string;
welcome_message?: string;
color_theme?: string;
expiration_days: number;
expiration_days?: number;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
feedback_enabled?: boolean;
+6 -6
View File
@@ -4,7 +4,7 @@ export interface Event {
slug: string;
event_type: string;
event_name: string;
event_date: string;
event_date: string | null;
customer_name?: string;
customer_email: string;
admin_email: string;
@@ -12,7 +12,7 @@ export interface Event {
color_theme?: string;
share_link: string;
created_at: string;
expires_at: string;
expires_at: string | null;
is_active: boolean;
is_archived: boolean;
archive_path?: string;
@@ -46,8 +46,8 @@ export interface Event {
export interface GalleryInfo {
event_name: string;
event_type: string;
event_date: string;
expires_at: string;
event_date: string | null;
expires_at: string | null;
is_active: boolean;
is_expired: boolean;
requires_password?: boolean;
@@ -97,10 +97,10 @@ export interface GalleryData {
id: number;
event_name: string;
event_type: string;
event_date: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string;
expires_at: string | null;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
+3 -3
View File
@@ -12,10 +12,10 @@ interface PhotoUrlOptions {
*/
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
if (watermarkEnabled && token) {
// Use the watermarked photo endpoint
return `/gallery/${slug}/photo/${photo.id}`;
// Use the watermarked photo endpoint (needs /api prefix)
return `/api/gallery/${slug}/photo/${photo.id}`;
}
// Use the static photo URL
return photo.url;
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"include-component-in-tag": false,
"include-v-in-tag": true,
"prerelease": true,
"prerelease-type": "beta",
"changelog-sections": [
{ "type": "feat", "section": "Features", "hidden": false },
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
{ "type": "revert", "section": "Reverts", "hidden": false },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "style", "section": "Styles", "hidden": true },
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true }
],
"packages": {
".": {
"release-type": "simple",
"changelog-path": "CHANGELOG.md",
"extra-files": [
{
"type": "json",
"path": "backend/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "frontend/package.json",
"jsonpath": "$.version"
}
]
}
}
}
+12 -1
View File
@@ -22,7 +22,18 @@
".": {
"release-type": "simple",
"changelog-path": "CHANGELOG.md",
"extra-files": []
"extra-files": [
{
"type": "json",
"path": "backend/package.json",
"jsonpath": "$.version"
},
{
"type": "json",
"path": "frontend/package.json",
"jsonpath": "$.version"
}
]
}
}
}