Compare commits

..

270 Commits

Author SHA1 Message Date
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
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(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Alternative Solutions Considered:

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

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

3. Host network mode
    REJECTED: Breaks container networking isolation

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

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

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

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

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

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

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

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

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

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

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

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

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

Technical Details:

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

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

Notes on Remaining Issues:

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

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

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

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

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

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

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

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

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

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

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
paul f6f1c31369 Fix mobile overlay and deps per #43
continuous-integration/drone/pr Build is failing
Test and Lint / backend-test (pull_request) Successful in 2m10s
Test and Lint / frontend-test (pull_request) Successful in 2m0s
2025-10-29 11:11:53 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
paul a1e9fb6ffc Fix setup clone path conflict for issue #32 2025-10-12 21:20:36 +02:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
paul 665ce5a6e7 Fix issues #31 #33 #34 #35 #36 2025-10-12 21:03:07 +02:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
paul 8c41dd626d Fix hero layout tile sizing and scroll hook 2025-10-06 15:15:34 +02:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul 775e417e55 Fix admin reference mode regressions 2025-10-02 23:39:17 +02:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
Gitea Actions Bot eb626be22c chore: bump version to 1.0.130 (backend + frontend) 2025-09-19 14:47:37 +00:00
paul aaaf59817b fix: stabilize uploads and guest feedback filters
Mirror to GitHub / mirror (push) Successful in 1m54s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m49s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-19 16:39:18 +02:00
paul 2a4d38813f feat: overhaul public landing page and backup tooling 2025-09-19 16:39:18 +02:00
Gitea Actions Bot ad9c6d63d3 chore: bump backend version to 1.0.129 2025-09-18 14:54:28 +00:00
paul 8c77b30de6 Default auth cookies to non-secure for HTTP installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m36s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 16:48:14 +02:00
Gitea Actions Bot e51347d0a1 chore: bump version to 1.0.128 (backend + frontend) 2025-09-18 14:05:43 +00:00
paul 71e7179145 Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 15:59:26 +02:00
Gitea Actions Bot bda76ff513 chore: bump backend version to 1.0.125 2025-09-18 10:49:54 +00:00
paul 097ce2c205 Fix native install schema gaps (closes #20)
Mirror to GitHub / mirror (push) Successful in 1m38s
Test and Lint / backend-test (push) Successful in 1m44s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Failing after 1m20s
Version and Release / trigger-drone (push) Has been skipped
2025-09-18 12:42:05 +02:00
Gitea Actions Bot 1d8be3d840 chore: bump frontend version to 1.0.127 2025-09-17 21:40:18 +00:00
paul aebb8e66cb Make lightbox feedback panel sticky on desktop
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m17s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-17 23:33:43 +02:00
Gitea Actions Bot ed2a278da2 chore: bump frontend version to 1.0.126 2025-09-17 21:22:44 +00:00
paul db2f5da66a Ensure gallery comment filter hides moderated comments
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m25s
Version and Release / version-bump (push) Successful in 1m46s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-17 23:15:28 +02:00
paul 19f8facc49 Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
2025-09-17 22:27:13 +02:00
paul b03760ab01 feat(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 2025-09-16 10:11:08 +02:00
paul 526dcd8dfc fix(gallery/filters): always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier 2025-09-16 09:57:35 +02:00
paul 5b2561b6f1 fix(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. 2025-09-16 09:28:12 +02:00
paul 3a6d06192a fix(gallery): feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) 2025-09-16 09:15:47 +02:00
paul 4b64b80b20 ui(gallery): feedback filter headline + horizontal compact icons (desktop+mobile); render only when feedback enabled 2025-09-16 09:11:32 +02:00
paul ff89f96e31 fix(gallery/sidebar): compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact 2025-09-16 09:03:24 +02:00
paul 465f997752 feat(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
Mirror to GitHub / mirror (push) Successful in 1m57s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 55s
Version and Release / trigger-drone (push) Has been skipped
2025-09-15 22:59:21 +02:00
paul 6948aaa92a feat(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 2025-09-15 22:59:21 +02:00
paul 4c7b49a5f6 fix(admin/feedback): use correct event id when rendering photo thumbnails
- Replace undefined eventId with route param id to build admin thumbnail URL
- Fixes runtime ReferenceError on /admin/events/:id/feedback when opening Feedback tab

Refs: #19
2025-09-15 22:59:21 +02:00
paul 6368f1027f feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled
- Respect requireNameEmail; prompt via identity modal before submitting feedback
- Wire feedback settings from GalleryView -> layouts via feedbackOptions

feat(lightbox): keep feedback usable while navigating

- Add initialShowFeedback prop; preserve panel state across navigation
- Offset Next button when feedback panel is open so it remains accessible
- Hide/avoid overlapping nav on small screens

Refs: #19
2025-09-15 22:59:21 +02:00
paul d64e7d08de feat(admin): refine header layout and logo placement
- Left-align logo across breakpoints; remove duplicate centered/mobile blocks
- Add date separator and spacing; keep header compact and readable

fix(admin): prevent category badge overlap in grid

- Move badge to top-left; make non-interactive; constrain width to avoid checkbox collisions

chore(docker): support ADMIN_PASSWORD in docker-compose

- Allow setting initial admin password via env for easier provisioning

chore(backend): normalize EOF newline in set-admin-password.js

Refs: admin-header-layout, category-badge-overlap, docker-admin-password
2025-09-15 22:59:21 +02:00
Gitea Actions Bot eb3751cb52 chore: bump frontend version to 1.0.125 2025-09-14 15:08:14 +00:00
paul 9fda54bd06 feat(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
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 17:02:45 +02:00
Gitea Actions Bot 0d77a3a0a8 chore: bump version to 1.0.124 (backend + frontend) 2025-09-14 14:25:37 +00:00
paul 0618b78725 feat(setup/docker): auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 16:20:08 +02:00
paul 0178e71c67 docs: add PUID/PGID note for Docker bind mounts to avoid permission issues
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-14 16:10:46 +02:00
Gitea Actions Bot aa9b3a0227 chore: bump version to 1.0.123 (backend + frontend) 2025-09-14 13:53:37 +00:00
paul 410a33fecf feat(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
Mirror to GitHub / mirror (push) Successful in 1m41s
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 15:46:35 +02:00
Gitea Actions Bot 05ebaaeedb chore: bump version to 1.0.122 (backend + frontend) 2025-09-09 18:54:09 +00:00
paul 84d0f63d36 feat(setup): remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:48:49 +02:00
Gitea Actions Bot 6a4b549d9f chore: bump version to 1.0.121 (backend + frontend) 2025-09-09 18:45:28 +00:00
paul f3604b438b fix(native): remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 56s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:40:17 +02:00
Gitea Actions Bot 531831e84b chore: bump backend version to 1.0.120 2025-09-09 18:28:39 +00:00
paul 90bb21e38b fix(cors): scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:23:25 +02:00
paul 2f1a137342 ci: make ghcr login non-fatal and gate pushes/scans on login success; build images regardless (supports transient GHCR outages)
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Has been skipped
2025-09-09 20:12:15 +02:00
paul adf576fbe1 fix(setup/update): detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-09-09 20:08:51 +02:00
Gitea Actions Bot 4264026bbe chore: bump backend version to 1.0.119 2025-09-09 18:06:15 +00:00
paul 24b4a314a9 fix(native/http): disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:00:48 +02:00
Gitea Actions Bot ba825823a0 chore: bump backend version to 1.0.118 2025-09-09 17:58:50 +00:00
paul fb16b7bbb8 feat(native): auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:52:46 +02:00
Gitea Actions Bot 8404125ff0 chore: bump version to 1.0.117 (backend + frontend) 2025-09-09 17:10:38 +00:00
paul 61ad2d61c1 feat(native): serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR)
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:04:39 +02:00
Gitea Actions Bot 9fd6b44487 chore: bump version to 1.0.116 (backend + frontend) 2025-09-09 15:47:41 +00:00
paul 9fe10bcce2 feat(native): build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:41:41 +02:00
Gitea Actions Bot f2abb40987 chore: bump version to 1.0.115 (backend + frontend) 2025-09-09 15:32:47 +00:00
paul 3697344cd0 fix(setup/native): handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:27:27 +02:00
Gitea Actions Bot 4aa0ff705f chore: bump version to 1.0.114 (backend + frontend) 2025-09-09 15:25:05 +00:00
paul dc482e614a fix(setup/native): Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:19:03 +02:00
Gitea Actions Bot 448882cfef chore: bump version to 1.0.113 (backend + frontend) 2025-09-09 13:31:10 +00:00
paul 7f9cb33a40 chore(native): ensure SQLite data dir exists in setup and at runtime; keep native paths consistent under /opt/picpeak/app
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m3s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 15:25:04 +02:00
Gitea Actions Bot 798f6211e0 chore: bump version to 1.0.112 (backend + frontend) 2025-09-09 09:46:52 +00:00
paul b992b151d3 fix(native): correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes
Mirror to GitHub / mirror (push) Successful in 1m27s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m8s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 11:25:41 +02:00
paul 87b8414e44 fix(setup/native): correct repo URL, paths, and systemd for native install; support sqlite in production knex config 2025-09-09 11:13:01 +02:00
paul ee13556c5c docs(readme): reflect new External Media reference mode and update roadmap (gallery feedback status)
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m14s
2025-09-06 10:16:08 +02:00
Gitea Actions Bot afeb35a446 chore: bump version to 1.0.111 (backend + frontend) 2025-09-06 07:22:38 +00:00
paul ab324f1928 fix(frontend): add missing externalMedia service and mount admin external-media routes; verify Vite build
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m37s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-06 09:15:49 +02:00
Gitea Actions Bot 78ab0ad2e9 chore: bump version to 1.0.110 (backend + frontend) 2025-09-05 22:06:43 +00:00
paul 49c77785e7 feat(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 – gallery feature request: https://github.com/the-luap/picpeak/issues/17
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-05 23:44:30 +02:00
Gitea Actions Bot 1d826accdc chore: bump version to 1.0.109 (backend + frontend) 2025-09-05 13:07:58 +00:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul e9171c7115 docs: follow-up on PR #15 — clarify VITE_API_URL usage, compose mounts, and admin routing (refs #15) 2025-09-05 15:01:16 +02:00
paul 758c085467 docs: clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs #18) 2025-09-05 15:01:16 +02:00
paul ecbc48815d docs(compose): fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs #18) 2025-09-05 15:01:16 +02:00
paul e91b138154 chore: remove unintended local artifacts and SQLite DB; update .gitignore (refs #18) 2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
paul 41857ec499 feat: implement feedback filter for liked/favorited photos (Issue #17)
Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
Gitea Actions Bot f7a8765f58 chore: bump version to 1.0.108 (backend + frontend) 2025-09-02 15:46:57 +00:00
paul 214f120f7a chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-02 17:40:23 +02:00
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
paul 67ff415840 fix: resolve feedback validation issues from GitHub issue #16
- Fixed backend validation to properly handle empty strings in validateGuestRequirements
- Added Boolean conversion for SQLite boolean values in feedback settings API response
- Created FeedbackIdentityModal component for collecting name/email when required
- Updated PhotoLikes, PhotoRating, and PhotoFavorites components to show modal when requireNameEmail is true
- Fixed issue where require_name_email field was not reaching frontend due to missing boolean conversion

This ensures that when 'Require Name & Email' is enabled, guests are prompted with a modal to provide their information before submitting feedback, preventing 400 Bad Request errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
Gitea Actions Bot 88659f1fa6 chore: bump frontend version to 1.0.107 2025-09-02 14:14:52 +00:00
paul c1e10f14a3 fix: resolve translation interpolation issue for download button
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 1m13s
Version and Release / trigger-drone (push) Successful in 3s
Fixed the download selected button not displaying count properly.
The translation key 'gallery.downloadSelected' was not receiving
the count parameter for interpolation, causing "{{count}}" to
display literally instead of the actual number.

Fixes the issue where the button showed:
- "Download {{count}} Selected" instead of "Download 2 Selected"
- "{{count}} ausgewählte herunterladen" instead of "3 ausgewählte herunterladen"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:08:14 +02:00
Gitea Actions Bot 0881a0fa71 chore: bump version to 1.0.106 (backend + frontend) 2025-09-01 21:03:36 +00:00
paul e91209f7cb fix: resolve multiple issues from GitHub issue #14
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m54s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed duplicate German translation for 'downloadSelected' button
- Added client_max_body_size configuration in nginx for file uploads
- Fixed date parsing in FeedbackModerationPanel to handle timestamps
- Fixed admin authentication context (req.admin vs req.user) in feedback routes
- Enhanced clipboard functionality with fallback for non-HTTPS contexts
- Fixed authentication token handling for numeric event IDs in uploads

These changes ensure comment moderation works properly, file uploads are configured correctly, and the UI handles all edge cases properly.

Fixes #14

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 22:56:44 +02:00
paul 828d6bc456 fix: correct script name in Gitea mirror workflow
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Has been skipped
- Fix script name from gitea-runner.sh to install-gitea-runner.sh
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:37:12 +02:00
paul f945573f09 chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Has started running
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-08-29 22:33:38 +02:00
paul 296430e4d7 fix: update Gitea mirror workflow to selectively remove scripts
- Only remove gitea-runner.sh instead of entire scripts directory
- Preserve useful deployment and utility scripts in GitHub mirror
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:33:38 +02:00
Gitea Actions Bot 7b517fa290 chore: bump version to 1.0.105 (backend + frontend) 2025-08-29 20:28:39 +00:00
paul 2c9a56f217 docs: update deployment guide with GitHub Container Registry images
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Added instructions for using pre-built images from ghcr.io
- Created docker-compose.production.yml for quick deployment with official images
- Updated deployment guide with two methods:
  1. Using pre-built images (fastest, recommended)
  2. Building from source (for customization)
- Updated SIMPLE_SETUP references to use new unified script
- Added specific version deployment instructions
- Maintained backward compatibility with local build process

The pre-built images eliminate build time and ensure consistent deployments
across environments. Users can now deploy PicPeak in minutes using:
- ghcr.io/the-luap/picpeak/backend:latest
- ghcr.io/the-luap/picpeak/frontend:latest

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:22:40 +02:00
paul 986b101040 fix: remove unnecessary publish-manifest job from Docker workflow
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Has been skipped
The publish-manifest job was failing because it tried to create manifests
from non-existent architecture-specific tags (latest-amd64, latest-arm64).

docker/build-push-action@v5 already creates multi-arch manifests automatically
when building for multiple platforms, making this job redundant.

The workflow now correctly builds and pushes multi-arch images in a single
step with proper manifest lists included.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:16:35 +02:00
Gitea Actions Bot 0c283717cb chore: bump version to 1.0.104 (backend + frontend) 2025-08-29 20:13:18 +00:00
paul 4029559954 feat: add GitHub Actions workflow for Docker image builds
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m11s
Version and Release / trigger-drone (push) Successful in 3s
- Created docker-build.yml workflow for automated Docker builds
- Configured GitHub Container Registry (ghcr.io) with GITHUB_TOKEN auth
- Added multi-architecture support (linux/amd64, linux/arm64)
- Integrated Trivy security scanning for vulnerability detection
- Implemented smart tagging based on branches, PRs, and releases
- Added build caching for improved performance
- Updated Dockerfiles with OCI labels for proper ghcr.io linking
- Created comprehensive README-DOCKER.md documentation

The workflow automatically builds and pushes images on:
- Push to main/develop branches
- Pull requests (build only, no push)
- Release publications
- Manual workflow dispatch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:07:19 +02:00
Gitea Actions Bot 9c943bc69a chore: bump version to 1.0.103 (backend + frontend) 2025-08-29 20:06:11 +00:00
paul 29a8ff914c feat: consolidate setup scripts and guides into unified solution
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Created unified SIMPLE_SETUP.md combining Docker and native installation guides
- Created universal scripts/setup.sh supporting both Docker and native installations
- Removed redundant setup files (simple-setup.md, simple-setup.sh, scripts/simple-setup.sh)
- Added intelligent installation method selection based on system resources
- Implemented update and uninstall functionality in unified script
- Enhanced with command-line options for unattended installations
- Improved cross-platform support (Ubuntu, Debian, RHEL/CentOS, Fedora, Raspberry Pi OS)

Fixes #7

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:59:39 +02:00
paul a73d217273 refactor: simplify setup file names
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m41s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Has been skipped
- Rename SETUP_GUIDE.md to simple-setup.md
- Rename setup-picpeak.sh to simple-setup.sh
- Update all internal references to use new filenames
- Simplify naming convention for easier understanding

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:23:02 +02:00
paul 1b4b497fdf chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:19:30 +02:00
paul 827eb4819b fix: update GitHub mirror action to support fine-grained personal access tokens
Mirror to GitHub / mirror (push) Failing after 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m14s
- Changed authentication from x-access-token to actual username (required for fine-grained tokens)
- Implemented git config url.insteadOf method for better token compatibility
- Added comprehensive token type detection and validation
- Improved error handling with detailed troubleshooting instructions
- Added clear documentation for both classic and fine-grained token setup
- Enhanced security by removing credentials from remote URLs
- Added automatic git config cleanup after push

Required permissions for fine-grained tokens:
- Repository access: the-luap/picpeak
- Contents: Read and Write
- Metadata: Read

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:24:49 +02:00
Gitea Actions Bot 086a4ca342 chore: bump version to 1.0.102 (backend + frontend) 2025-08-24 09:18:00 +00:00
paul 6de64a1df1 fix: resolve port configuration issues and database column mismatch
Mirror to GitHub / mirror (push) Failing after 40s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m1s
Version and Release / version-bump (push) Successful in 1m6s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed database query in adminDashboard.js using non-existent 'created_at' column
  Changed to use 'scheduled_at' for email_queue table queries
- Updated frontend/.env.example to default to Docker configuration (port 3001/api)
- Clarified DEPLOYMENT_GUIDE.md with separate frontend/backend configuration sections
- Added explicit port configuration warnings to prevent future mismatches
- Added beta features section to README for download protection and deployment script

The 500 errors were caused by:
1. Frontend .env pointing to wrong port (3002 instead of 3001)
2. Database query using 'created_at' instead of 'scheduled_at' for email_queue

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 3074748bbc fix: correct malformed gallery URLs in admin panel View Gallery links
Fixed issue where full URLs in share_link field were incorrectly being prepended
with `/gallery/` prefix, resulting in malformed URLs like:
`/gallery/http://localhost:3000/gallery/event-slug/token`

The fix now properly handles both formats stored in the database:
- Full URLs (from adminEvents.js): Used directly
- Relative paths (from events.js): Prepended with `/gallery/`

This ensures View Gallery links work correctly regardless of which backend
endpoint created the event.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
paul 934d6ddc58 fix: resolve GitHub issues #4, #8, #9, and #10
- Fix missing database columns for password reset (#8)
  - Add must_change_password column to admin_users table
  - Add password_changed_at column for tracking password changes

- Fix feedback functionality (#9)
  - Add require_moderation column to event_feedback_settings table
  - Add missing host_name column to events table

- Add download control features (#10)
  - Add allow_downloads, disable_right_click, watermark_downloads columns to events
  - Implement download restrictions in gallery endpoints
  - Update event creation and update endpoints to support new fields
  - Prevent downloads when disabled for an event

- Login functionality (#4) verified working with proper credentials

All database migrations included and tested with Docker environment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:11:53 +02:00
Gitea Actions Bot a699a0477b chore: bump backend version to 1.0.101
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-08-03 18:14:51 +00:00
paul ed0243ec39 fix: remove updated_at field from password reset query
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
- The events table doesn't have an updated_at column
- Fixes PostgreSQL error 42703 when resetting passwords
- Password hash update now works correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 20:08:55 +02:00
Gitea Actions Bot ac31798bf5 chore: bump backend version to 1.0.100
continuous-integration/drone/push Build is passing
2025-08-03 17:58:27 +00:00
paul 65d796b9f0 fix: correct password generator function name in reset password route
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m46s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 1m38s
Version and Release / trigger-drone (push) Has been skipped
- Change generatePassword to generateReadablePassword
- Fixes TypeError when resetting gallery passwords
- The function generatePassword doesn't exist in passwordGenerator.js

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 19:51:17 +02:00
paul 6389b9df3f fix: update all deployment guide links in README.md
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m18s
Test and Lint / frontend-test (push) Successful in 1m56s
continuous-integration/drone/push Build is passing
- Change all links from DEPLOYMENT.md to DEPLOYMENT_GUIDE.md
- Fixed 3 occurrences: documentation section, getting started section, and footer
- Matches the actual filename in the repository

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:25:04 +02:00
paul 87d1761091 docs: add warnings about $ character in Docker Compose passwords
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Has been skipped
continuous-integration/drone/push Build is passing
- Add clear warnings in .env.example about $ variable substitution
- Update DEPLOYMENT_GUIDE.md with password generation commands that exclude $
- Add troubleshooting section for Docker Compose variable substitution errors
- Provide solutions: avoid $, escape as $$, or use quotes

Fixes issue where passwords containing $ cause Docker Compose warnings
and potential authentication failures.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:23:48 +02:00
Gitea Actions Bot fda132eed4 chore: bump frontend version to 1.0.100
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 22:22:28 +00:00
paul 1cadce196b fix: update deployment guide with critical URL configuration and nginx port fixes
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m31s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m56s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add prominent warning about FRONTEND_URL configuration requiring exact port match
- Add comprehensive troubleshooting section for 502/CORS login failures
- Fix nginx.conf to use correct backend port (3001 instead of 3000)
- Document common deployment issues and their solutions
- Explain Docker DNS caching issues after container restarts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 00:17:41 +02:00
Gitea Actions Bot 840b8870ec chore: bump version to 1.0.99 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 14:33:40 +00:00
paul ad495a92c4 fix: improve admin credentials display and configuration
Mirror to GitHub / mirror (push) Successful in 29s
Test and Lint / backend-test (push) Successful in 1m32s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- Display email address instead of username in migration output
- Use environment variables for admin email configuration
- Update deployment guide with clear admin setup instructions
- Add note that login requires email address, not username
- Fix GitHub URL to correct repository
- Remove obsolete version field from docker-compose.yml

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 16:28:32 +02:00
Gitea Actions Bot b428543452 chore: bump version to 1.0.98 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-25 13:09:41 +00:00
paul 6492cb9ec8 refactor: simplify deployment structure with direct port exposure
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m2s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 2s
- Removed nginx/certbot/umami from docker-compose.yml
- Services now expose ports directly (frontend:3000, backend:3001)
- Updated deployment guide with reverse proxy setup instructions
- Changed all docker-compose commands to use docker compose (no hyphen)
- Removed separate dev deployment files (.env.dev, docker-compose.dev.yml)
- Simplified .env.example for production use
- Added comprehensive reverse proxy examples (nginx, Traefik, Caddy)

BREAKING CHANGE: Deployment now requires external reverse proxy for SSL/HTTPS

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 15:04:51 +02:00
Gitea Actions Bot 0e0a0b91d1 chore: bump version to 1.0.97 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:59:57 +00:00
paul f8fb1c3f4b fix: resolve backend startup errors in development
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 45s
Version and Release / trigger-drone (push) Successful in 3s
- Added STORAGE_PATH environment variable and volume mount for storage directory
- Fixed authSecurity functions to check if login_attempts table exists before using it
- Prevents errors when running with only core migrations (new deployments)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:54:32 +02:00
Gitea Actions Bot b108f6fe1c chore: bump version to 1.0.96 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-25 12:46:48 +00:00
paul 61299a33c4 fix: resolve development environment issues
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 39s
Version and Release / trigger-drone (push) Successful in 3s
- Updated frontend to Node 20 to fix Vite crypto.hash error
- Removed mailhog service as not needed for development
- Updated email configuration to be disabled by default in dev
- Fixed frontend port mapping to use 3005 consistently
- Added script to show/reset admin credentials
- Removed unnecessary storage volume mount

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-25 14:41:59 +02:00
370 changed files with 51372 additions and 13051 deletions
-114
View File
@@ -1,114 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
branch:
- main
- develop
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: release
steps:
# Build Backend Release
- name: build-backend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
# Build Frontend Release
- name: build-frontend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
# -------- NEW: Publish Docker images to GitHub Container Registry --------
- name: push-backend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- name: push-frontend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
event:
- tag
-39
View File
@@ -1,39 +0,0 @@
# Development Environment with PostgreSQL
# Copy this to .env for PostgreSQL development with Docker Compose
# JWT Secret (development only)
JWT_SECRET=dev-secret-key-do-not-use-in-production
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak_dev
DB_PASSWORD=dev_password_123
DB_NAME=picpeak_dev
# Redis Configuration
REDIS_PASSWORD=dev_redis_pass
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@localhost
# Email Configuration (Mailhog for development)
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@picpeak.local
# Application URLs
FRONTEND_URL=http://localhost:3000
ADMIN_URL=http://localhost:3001
VITE_API_URL=http://localhost:3001/api
# Timezone
TZ=UTC
# Analytics (Optional - leave empty for development)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
VITE_UMAMI_SHARE_URL=
+73 -51
View File
@@ -1,65 +1,87 @@
# PicPeak Development Environment Configuration
# Copy this file to .env for local development
# PicPeak Environment Configuration
# Copy this file to .env and update with your values
# SECURITY WARNING: This configuration is for development only!
# For production, use .env.production.example
# Environment
NODE_ENV=production
# JWT Secret (Change in production!)
# Generate secure secret with: openssl rand -base64 32
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Application URLs (Docker Compose development setup)
ADMIN_URL=http://localhost:3005
FRONTEND_URL=http://localhost:3005
BACKEND_URL=http://localhost:3001
# Database Configuration (PostgreSQL for development)
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_HOST=db
DB_PORT=5432
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=picpeak
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
# Email Configuration
# For development with docker-compose.dev.yml:
# Access Mailhog UI at: http://localhost:8025
SMTP_HOST=mailhog
SMTP_PORT=1025
# For Gmail: use app-specific password
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@localhost
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
# For development without Docker, use real SMTP:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-password
# EMAIL_FROM=PicPeak Dev <your-email@gmail.com>
# Application URLs
# Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin.
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# Backend Port Configuration
PORT=3001
# 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.
VITE_API_URL=/api
# Optional: Umami Analytics Backend Config
# NOTE: Primary configuration through Admin UI > Settings > Analytics
# These are fallback values for server-side tracking
# UMAMI_URL=https://analytics.example.com
# UMAMI_WEBSITE_ID=your-website-id
# UMAMI_HASH_SALT=your-hash-salt
# Port Configuration (optional)
# BACKEND_PORT=3001
# FRONTEND_PORT=3000
# DB_PORT=5432
# REDIS_PORT=6379
# Development Features
NODE_ENV=development
LOG_LEVEL=debug
# 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
# Storage Settings (optional)
DEFAULT_EXPIRATION_DAYS=30
WARNING_DAYS_BEFORE_EXPIRY=7
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Admin Setup Notes:
# 1. Run 'docker-compose -f docker-compose.dev.yml up -d'
# 2. Run 'docker-compose -f docker-compose.dev.yml exec backend npm run migrate'
# 3. Admin credentials will be auto-generated
# 4. Check backend/ADMIN_CREDENTIALS.txt for login details
# 5. Change password on first login (required)
# Timezone
TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
VITE_UMAMI_SHARE_URL=
# Storage variables (host paths)
# These control where data is stored on the host. Defaults are local folders.
APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
# should you change VITE_API_URL at build time.
-107
View File
@@ -1,107 +0,0 @@
# PicPeak Production Configuration
# Copy this file to .env and update with your production values
# ============================================
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
# ============================================
# JWT Secret - REQUIRED (minimum 32 characters)
# Generate with: openssl rand -base64 32
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
# Application URLs - REQUIRED (your actual domain)
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
ADMIN_URL=https://your-domain.com
# ============================================
# DATABASE CONFIGURATION - REQUIRED
# ============================================
# PostgreSQL Configuration (Recommended for production)
DATABASE_CLIENT=pg
DB_HOST=db # Use 'db' for Docker Compose, or external host
DB_PORT=5432
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
# ============================================
# EMAIL CONFIGURATION - REQUIRED
# ============================================
# Example: Gmail with App Password
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-16-char-app-password
# EMAIL_FROM=Your Name <your-email@gmail.com>
# Example: SendGrid
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=YOUR-SENDGRID-API-KEY
EMAIL_FROM=PicPeak <noreply@your-domain.com>
# ============================================
# ADMIN SETUP - AUTO-GENERATED
# ============================================
# NOTE: Admin credentials are automatically generated during setup
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
# ============================================
# OPTIONAL CONFIGURATION
# ============================================
# Umami Analytics (Optional - Fallback values)
# Primary config via Admin UI > Settings > Analytics
# UMAMI_URL=https://analytics.your-domain.com
# UMAMI_WEBSITE_ID=your-website-id
# UMAMI_HASH_SALT=your-hash-salt # Required if using Umami
# Frontend Analytics (Optional - Fallback values)
# VITE_UMAMI_URL=https://analytics.your-domain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
# ============================================
# PERFORMANCE & SECURITY TUNING
# ============================================
NODE_ENV=production
PORT=3001
LOG_LEVEL=info
# Backend URL (if different from frontend)
# BACKEND_URL=https://api.your-domain.com
# Storage Settings
DEFAULT_EXPIRATION_DAYS=30
WARNING_DAYS_BEFORE_EXPIRY=7
# Security Settings (Defaults are secure)
BCRYPT_ROUNDS=12
SESSION_TIMEOUT_MINUTES=60
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=100 # per window
# Connection Pool (Adjust based on load)
DB_POOL_MIN=5
DB_POOL_MAX=25
# ============================================
# DOCKER COMPOSE SPECIFIC
# ============================================
# Traefik Configuration (if using Traefik)
DOMAIN=your-domain.com
LETSENCRYPT_EMAIL=admin@your-domain.com
# Volume Paths (Docker)
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
ARCHIVE_PATH=/app/storage/events/archived
-88
View File
@@ -1,88 +0,0 @@
name: Mirror to GitHub
on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for proper mirroring
- name: Setup Git
run: |
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Remove sensitive files and directories
run: |
echo "Current files before cleanup:"
ls -la | head -10 || true
echo "..."
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .gitea/ || true
rm -rf scripts/ || true
rm -rf .drone* || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf storage/ || true
echo "Sensitive files removal completed"
# Add and commit the cleanup if there are changes
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore: remove sensitive files for GitHub mirror"
echo "✅ Committed cleanup of sensitive files"
else
echo "✅ No sensitive files to remove"
fi
echo "Final file structure (top level):"
ls -la | head -10 || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
exit 1
else
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote added:"
git remote -v
# Push to GitHub main branch
echo "Pushing to GitHub..."
git push github main --force
echo "✅ Push to GitHub completed!"
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror"
-52
View File
@@ -1,52 +0,0 @@
name: Test and Lint
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Run backend linting
working-directory: ./backend
run: npm run lint || true # Continue on lint errors for now
- name: Run backend tests
working-directory: ./backend
run: npm test || true # Continue on test failures for now
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci --legacy-peer-deps
- name: Run frontend linting
working-directory: ./frontend
run: npm run lint || true # Continue on lint errors for now
- name: Build frontend
working-directory: ./frontend
run: npm run build
-267
View File
@@ -1,267 +0,0 @@
name: Version and Release
on:
push:
branches: [ main ]
paths-ignore:
- '**.md'
- '.gitea/**'
- '.drone.yml'
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.version.outputs.new_version }}
version_changed: ${{ steps.version.outputs.version_changed }}
component_changed: ${{ steps.version.outputs.component_changed }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN || github.token }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Configure Git
run: |
git config --global user.name 'Gitea Actions Bot'
git config --global user.email 'actions@gitea.local'
- name: Detect changes and bump version
id: version
run: |
set -e # Exit on error
echo "=== Debug Info ==="
echo "GitHub event before: ${{ github.event.before }}"
echo "GitHub SHA: ${{ github.sha }}"
echo "Current directory: $(pwd)"
echo "Git log (last 5): $(git log --oneline -5)"
# Get the commit range for changed files
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
# First commit or no previous commit, check against HEAD~1 if it exists
if git rev-parse HEAD~1 >/dev/null 2>&1; then
COMMIT_RANGE="HEAD~1..HEAD"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
echo "First commit detected, checking all files"
CHANGED_FILES=$(git ls-files)
fi
fi
echo "Changed files:"
echo "$CHANGED_FILES"
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
echo "Backend files changed: $BACKEND_CHANGED"
echo "Frontend files changed: $FRONTEND_CHANGED"
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
# Determine what to update based on changes
BACKEND_UPDATE=false
FRONTEND_UPDATE=false
COMPONENT_CHANGED="none"
if [ "$ROOT_CHANGED" -gt 0 ]; then
# Root changes affect both components
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
SOURCE_VERSION=$BACKEND_VERSION
echo "Root changes detected - updating both components"
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Both components changed
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
# Use the higher version as source
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
SOURCE_VERSION=$BACKEND_VERSION
else
SOURCE_VERSION=$FRONTEND_VERSION
fi
echo "Both backend and frontend changed - updating both"
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
# Only backend changed
BACKEND_UPDATE=true
COMPONENT_CHANGED="backend"
SOURCE_VERSION=$BACKEND_VERSION
echo "Only backend changed - updating backend"
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Only frontend changed
FRONTEND_UPDATE=true
COMPONENT_CHANGED="frontend"
SOURCE_VERSION=$FRONTEND_VERSION
echo "Only frontend changed - updating frontend"
else
echo "No relevant changes detected"
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "component_changed=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
echo "Component changed: $COMPONENT_CHANGED"
echo "Source version: $SOURCE_VERSION"
echo "Backend update: $BACKEND_UPDATE"
echo "Frontend update: $FRONTEND_UPDATE"
# Calculate new version
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
MAJOR="${version_parts[0]}"
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
# Update versions in package.json files
if [ "$BACKEND_UPDATE" = true ]; then
echo "Updating backend version to $NEW_VERSION"
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
if [ "$FRONTEND_UPDATE" = true ]; then
echo "Updating frontend version to $NEW_VERSION"
cd frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes to commit
if [[ -n $(git status --porcelain) ]]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit version bump
if: steps.version.outputs.version_changed == 'true'
run: |
set -e # Exit on any error
# First, ensure we have the latest changes
echo "Fetching latest changes..."
git fetch origin main
# Check if we're behind and need to update
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Local is behind remote, pulling changes..."
git pull origin main --no-rebase
fi
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
elif [ "$COMPONENT" = "frontend" ]; then
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
# Pull latest changes before pushing to avoid conflicts
echo "Pulling latest changes from origin/main..."
if ! git pull --rebase origin main; then
echo "Rebase failed, attempting to resolve..."
# If rebase fails, abort and try a regular merge
git rebase --abort || true
git pull origin main --no-rebase
fi
# Push the changes with retry logic
echo "Pushing version bump..."
PUSH_SUCCESS=false
for i in 1 2 3; do
echo "Push attempt $i of 3..."
# Try to push
if git push origin main 2>&1; then
echo "Successfully pushed version bump on attempt $i"
PUSH_SUCCESS=true
break
else
echo "Push failed on attempt $i"
if [ $i -lt 3 ]; then
echo "Waiting 5 seconds before retry..."
sleep 5
echo "Pulling latest changes..."
git fetch origin main
# Try rebase first, fall back to merge
if ! git rebase origin/main; then
echo "Rebase failed, trying merge..."
git rebase --abort 2>/dev/null || true
git pull origin main --no-rebase
fi
fi
fi
done
if [ "$PUSH_SUCCESS" = "false" ]; then
echo "ERROR: Failed to push after 3 attempts"
exit 1
fi
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
run: |
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
elif [ "$COMPONENT" = "frontend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
fi
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
git push origin "v${{ steps.version.outputs.new_version }}"
trigger-drone:
needs: version-bump
if: needs.version-bump.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Drone Build
run: |
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
+213
View File
@@ -0,0 +1,213 @@
# Docker Build and Push Workflow
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
## Features
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
- 🏷️ **Smart tagging** based on branches, versions, and commits
- 🔒 **Security scanning** with Trivy vulnerability scanner
- 💾 **Build caching** for faster subsequent builds
- 📊 **Build summaries** in GitHub Actions UI
## Authentication
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
### Required Permissions
The workflow automatically sets the necessary permissions:
- `contents: read` - To checkout the repository
- `packages: write` - To push images to ghcr.io
- `security-events: write` - To upload security scan results
## Image Tags
Images are automatically tagged based on the trigger event:
| Event | Tags Generated |
|-------|---------------|
| Push to main | `latest`, `main`, `main-<short-sha>` |
| Push to develop | `develop`, `develop-<short-sha>` |
| Pull Request | `pr-<number>` |
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
| Manual trigger | Based on branch + optional push |
## Usage
### Pull Images
Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/the-luap/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
```
### Using in Docker Compose
```yaml
version: '3.8'
services:
backend:
image: ghcr.io/the-luap/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/the-luap/picpeak/frontend:latest
ports:
- "80:80"
```
### Using in Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: picpeak-backend
spec:
replicas: 3
template:
spec:
containers:
- name: backend
image: ghcr.io/the-luap/picpeak/backend:latest
imagePullPolicy: Always
```
## Manual Workflow Trigger
You can manually trigger the workflow from the Actions tab:
1. Go to Actions → "Build and Push Docker Images"
2. Click "Run workflow"
3. Select branch and whether to push images
4. Click "Run workflow"
## Security Scanning
The workflow includes Trivy vulnerability scanning that:
- Scans for CRITICAL and HIGH severity vulnerabilities
- Uploads results to GitHub Security tab
- Available under Security → Code scanning alerts
## Build Optimization
The workflow uses several optimization techniques:
1. **GitHub Actions Cache**: Speeds up builds by caching layers
2. **Multi-stage builds**: Reduces final image size
3. **Parallel builds**: Backend and frontend build simultaneously
4. **Smart rebuilds**: Only rebuilds changed components
## Troubleshooting
### Permission Denied Errors
If you encounter permission errors when pushing images:
1. **First-time setup**: The first push creates a private package. You may need to:
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
- Link the package to your repository
- Set package visibility (public/private)
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
### Build Failures
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
- Missing dependencies in package.json
- Dockerfile syntax errors
- Network issues during package installation
### Image Not Found
If images aren't visible after successful push:
- Check package visibility settings
- Ensure you're authenticated to pull private images:
```bash
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
```
## Package Management
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
To save storage, you can delete old versions:
1. Go to package settings
2. Click on "Manage versions"
3. Select versions to delete
4. Click "Delete selected versions"
### Set Retention Policy
Configure automatic cleanup in package settings:
1. Go to package settings
2. Click on "Manage Actions access"
3. Set retention days for untagged versions
## Best Practices
1. **Use semantic versioning** for releases (e.g., v1.2.3)
2. **Test images locally** before pushing to production
3. **Monitor security alerts** from Trivy scans
4. **Clean up old images** regularly to save storage
5. **Use specific tags** in production (avoid `latest`)
## Advanced Configuration
### Custom Registry
To use a different registry, update the workflow:
```yaml
env:
REGISTRY: docker.io # or your custom registry
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
```
### Additional Platforms
To build for more platforms:
```yaml
platforms: linux/amd64,linux/arm64,linux/arm/v7
```
### Custom Build Arguments
Add build arguments in the workflow:
```yaml
build-args: |
NODE_VERSION=20
API_URL=${{ secrets.API_URL }}
```
## Related Documentation
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
- [Docker Build Action](https://github.com/docker/build-push-action)
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
+297
View File
@@ -0,0 +1,297 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/develop branches (builds 'latest' or branch-tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Manual workflow dispatch
on:
push:
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
inputs:
push:
description: 'Push images to registry'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
env:
REGISTRY: ghcr.io
BACKEND_IMAGE_NAME: ${{ github.repository }}/backend
FRONTEND_IMAGE_NAME: ${{ github.repository }}/frontend
jobs:
build-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- 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: |
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
format: 'sarif'
output: 'trivy-backend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- 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
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- 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: |
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
format: 'sarif'
output: 'trivy-frontend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- 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
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
# automatically creates multi-arch manifests when building for multiple platforms.
# The images are already properly tagged and include all architectures.
summary:
needs: [build-backend, build-frontend]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Build Summary
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
echo "✅ **Backend**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend**: Build failed" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend**: Build failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
+72
View File
@@ -0,0 +1,72 @@
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
# 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: beta
- 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
+71
View File
@@ -0,0 +1,71 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# 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
+22
View File
@@ -72,3 +72,25 @@ logs/
storage/
data/
certbot/
# Ignore local contributor guide copy
AGENTS.md
CLAUDE.md
# Working/planning documents (not for release)
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling
.playwright-mcp/
# Local SQLite files in backend
backend/*.sqlite*
+3
View File
@@ -0,0 +1,3 @@
{
".": "2.3.0-beta.0"
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "2.3.0"
}
+293
View File
@@ -0,0 +1,293 @@
# Changelog
All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.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)
### Bug Fixes
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* 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))
## [2.2.3](https://github.com/the-luap/picpeak/compare/v2.2.2...v2.2.3) (2026-01-08)
### Bug Fixes
* **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))
## [2.2.2](https://github.com/the-luap/picpeak/compare/v2.2.1...v2.2.2) (2026-01-08)
### Bug Fixes
* 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))
## [2.2.1](https://github.com/the-luap/picpeak/compare/v2.2.0...v2.2.1) (2026-01-08)
### Bug Fixes
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* 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))
## [2.2.0](https://github.com/the-luap/picpeak/compare/v2.1.1...v2.2.0) (2026-01-08)
### Features
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
### Bug Fixes
* 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))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
## [2.1.1](https://github.com/the-luap/picpeak/compare/v2.1.0...v2.1.1) (2026-01-07)
### Bug Fixes
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* 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))
## [2.1.0](https://github.com/the-luap/picpeak/compare/v2.0.0...v2.1.0) (2026-01-07)
### Features
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* 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))
### Bug Fixes
* **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))
## [2.0.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v2.0.0) (2026-01-03)
### ⚠ 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))
* **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))
* 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))
* 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))
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
* 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)
* **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))
* **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))
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
* 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))
* 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/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))
* 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))
* 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))
* 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))
* 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 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))
* **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))
### 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))
## [1.2.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v1.2.0) (2026-01-03)
### Features
* **Event Rename**: Safe event renaming with automatic slug updates, old URL redirects via `slug_redirects` table, and optional email notifications to clients
* **Optional Event Fields**: Make customer name, email, and admin email fields optional via admin settings with "(optional)" labels in forms
* **Photo Filtering**: Filter photos by rating, likes, favorites, and comments with a new PhotoFilterPanel component
* **Photo Export**: Export filtered photo selections as ZIP, generate Capture One/Lightroom-compatible XMP sidecar files, or export metadata lists
* **Custom CSS Templates**: 3 customizable CSS template slots with live preview, XSS-safe sanitization, and per-event template assignment
* **Apple Liquid Glass Theme**: Starter CSS template inspired by iOS 26 / macOS Tahoe Liquid Glass design with glass morphism effects, Apple SF Pro fonts, and responsive layout
* **Liquid Glass Dark Theme**: Neon-accented dark glass theme with animated gradient backgrounds
* **Image Security Settings**: Per-event download protection with configurable protection levels (basic, standard, enhanced, maximum), canvas rendering, DevTools detection, and right-click prevention
* **Automated Releases**: Release Please integration for automatic versioning, changelog generation, and GitHub releases that trigger Docker image builds
### Bug Fixes
* **Date Parsing**: Fix event date formatting in slugs (now uses YYYY-MM-DD format correctly)
* **Search Placeholder**: Fix search field placeholder visibility in glass-styled sidebar
* **Vite Proxy**: Fix Vite dev server proxy port configuration
* **Photo Export Button**: Fix export button staying disabled when photos are selected
* **Boolean Parsing**: Fix boolean parsing in publicSettings.js for optional fields
* **Translation Keys**: Add missing `common.optional` translation key in locales
### Security
* Fix critical vulnerabilities and harden application security
* Add CSS sanitizer utility blocking XSS vectors in custom templates
* Implement secure gallery CSS endpoint for template delivery
### Code Refactoring
* Add Photo and Settings service layers for better code organization
* Phase 1 code consolidation with service layer architecture
* Modular settings page with feature-based tab components
* Create photoFilterBuilder utility for query construction
* Add eventRenameService for safe event operations
### Documentation
* Add comprehensive REFACTORING_PLAN.md for codebase improvement roadmap
* Update README roadmap with implemented features (Download Protection, Gallery Templates, Filtering & Export)
* Add test specification documents for all new features
### Database Migrations
* `049_add_slug_redirects.js` - Store old slugs for URL redirects after rename
* `050_add_optional_event_fields_settings.js` - Settings for optional form fields
* `051_add_photo_filter_indexes.js` - Performance indexes for photo filtering
* `052_add_css_templates.js` - CSS template storage with 3 slots
* `053_add_liquid_glass_templates.js` - Apple Liquid Glass and Dark theme starter templates
---
## [1.1.15] - Previous Release
Initial stable release with core functionality.
-368
View File
@@ -1,368 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Product Overview
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
## Architecture Overview
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
- **Storage**: File-based with active/archived separation
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
- **Analytics**: Umami integration for engagement tracking
## Essential Commands
### Backend Development
```bash
cd backend
npm install # Install dependencies
npm run migrate # Initialize database schema
npm run dev # Start with hot-reload (port 3001)
npm test # Run Jest tests
npm run lint # ESLint checks
```
### Running a Single Test
```bash
cd backend
npm test -- path/to/test.test.js
npm test -- --testNamePattern="test name"
```
### Production Deployment
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
- Docker Compose deployment
- PM2 deployment
- Manual installation
- Non-nginx deployment options
- SSL/HTTPS setup
- Troubleshooting guide
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
1. **File-Based System**: Drop photos in folders → automatic gallery creation
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
3. **Password Protection**: Secure access with customizable passwords
4. **Automatic Archiving**: ZIP compression and storage after expiration
5. **Email Notifications**: Creation, warning, and expiration notifications
6. **Analytics**: Umami tracking for views, downloads, and engagement
### Folder Structure
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ └── individual/
│ └── birthday-emma-2024-07-20/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
## Frontend Implementation Requirements
### Design Style (scrappbook.de-inspired)
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
- **Layout**: Minimalist, modular sections with grid-based photo displays
- **Aesthetic**: Professional yet approachable, photographer-focused
### Key Frontend Components to Build
1. **Landing Page**: Password entry with event preview
2. **Gallery View**:
- Responsive photo grid with lazy loading
- Toggle between collages/individual photos
- Prominent expiration banner
- Download urgency indicators
3. **Photo Lightbox**: Full-screen viewing with zoom
4. **Mobile-First**: Responsive design with touch gestures
5. **Personalization**: Dynamic theming per event type
### User Experience Priorities
- Clear expiration warnings (sticky banner)
- One-click "Download All" for urgent galleries
- Smooth image loading with skeleton screens
- Intuitive navigation between photo categories
- Professional presentation matching photographer branding
## Key Architecture Patterns
### Authentication Flow
- JWT-based with separate tokens for admin and gallery access
- Gallery tokens include event-specific claims
- Auth middleware: `backend/src/middleware/auth.js`
- `adminAuth` - Admin panel protection
- `photoAuth` - Protected photo access
- `verifyGalleryAccess` - Gallery-specific validation
### Database Schema (Knex/SQLite)
Main tables:
- `events` - Gallery metadata with expiration, custom messages, themes
- `photos` - Photo records linked to events
- `access_logs` - IP-based usage tracking
- `email_queue` - Async email processing
- `admin_users` - Admin authentication
### Service Architecture
Background services run as separate processes:
- **emailService**: Processes email queue with retry logic
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
- **backupService**: Scheduled backups with checksum-based change detection
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
- `/api/gallery/*` - Public gallery endpoints
- `/api/auth/*` - Authentication endpoints
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
## Critical Implementation Notes
1. **Security**: All gallery access requires valid JWT with event-specific claims
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
4. **File Processing**: Sharp library for thumbnail generation (300x300)
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
- `JWT_SECRET` - Token signing
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
- `SMTP_*` - Email configuration
- `DB_*` - PostgreSQL credentials (production)
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
- `UMAMI_WEBSITE_ID` - Website ID from Umami
### Frontend (.env)
- `VITE_API_URL` - Backend API URL
- `VITE_UMAMI_URL` - Umami analytics URL
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
## Testing Approach
- Jest with Supertest for API testing
- Test files in `__tests__` directories
- Database migrations run before tests
- Mock email sending in tests
## Umami Analytics Integration
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
### Tracked Events:
- **Gallery Events**:
- `gallery_password_entry` - Password attempts (success/failure)
- `gallery_photo_view` - Individual photo views
- `gallery_photo_download` - Single photo downloads
- `gallery_bulk_download` - Bulk/all photo downloads
- `gallery_expired` - Expired gallery access attempts
- **Admin Events**:
- `admin_login` - Admin authentication
- `admin_event_created` - New event creation
- `admin_event_archived` - Event archiving
- `admin_event_deleted` - Event deletion
- `admin_settings_updated` - Settings changes
- **User Behavior**:
- Search queries (with debouncing)
- Expiration warning views
- Page views with automatic tracking
### Setup:
1. Install Umami (self-hosted or cloud)
2. Create a website in Umami dashboard
3. Set environment variables:
```
VITE_UMAMI_URL=https://your-umami-instance.com
VITE_UMAMI_WEBSITE_ID=your-website-id
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
```
### Analytics Dashboard:
- Admin panel includes analytics page at `/admin/analytics`
- Summary view with key metrics
- Option to embed full Umami dashboard
- Real-time event tracking
## Accessibility & Performance Features
### Accessibility (WCAG 2.1 AA Compliance)
- **Error Boundaries**: Graceful error handling with recovery options
- **Skip Links**: Skip to main content for keyboard navigation
- **ARIA Labels**: Proper labeling for screen readers
- **Focus Management**: Focus trap in modals, visible focus indicators
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
- **Loading States**: Skeleton screens instead of spinners for better UX
- **Offline Support**: Visual indicator when offline
- **Form Validation**: Accessible error messages with aria-describedby
### Performance Optimizations
- **Lazy Loading**: Images load on scroll with Intersection Observer
- **Skeleton Screens**: Instant visual feedback during loading
- **Error Recovery**: Component-level error boundaries prevent full page crashes
- **Optimistic Updates**: Immediate UI updates with background sync
- **Debounced Search**: Prevents excessive API calls
- **Analytics**: Non-blocking Umami integration
### Component Library Enhancements
- `<ErrorBoundary>` - Catches and displays errors gracefully
- `<PageErrorBoundary>` - Full-page error recovery
- `<Skeleton>` - Flexible skeleton loader with variants
- `<OfflineIndicator>` - Network status monitoring
- `<SkipLink>` - Accessibility navigation
- `useFocusTrap` - Modal focus management hook
- `useOnlineStatus` - Network status hook
## Theme System & Branding
### Theme Features
- **Dynamic Theming**: CSS variables for runtime theme switching
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
- **Customization Options**:
- Primary/Accent/Background/Text colors
- Font family selection
- Border radius (none, sm, md, lg)
- Custom logo upload
- Custom CSS injection
- **Event-Specific Themes**: Override global theme per gallery
- **Live Preview**: Real-time theme changes in admin panel
### Theme Context API
```typescript
const { theme, setTheme, setThemeByName } = useTheme();
```
### Branding Settings
- Company name, tagline, and support email
- Custom footer text
- Optional watermarking on downloads
- Logo upload for gallery header
### CSS Variables
```css
--color-primary: #5C8762;
--color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', sans-serif;
--border-radius: 0.5rem;
```
## Backup Service
### Overview
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
### Features
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
- **Email Notifications**: Alerts on backup failure, optional success notifications
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
- **Progress Tracking**: Database storage of backup history, file states, and statistics
### Configuration
Backup settings are stored in `app_settings` table with `backup_` prefix:
- `backup_enabled`: Enable/disable the service
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
- `backup_destination_type`: 'local', 'rsync', or 's3'
- `backup_retention_days`: How long to keep backup history
- `backup_include_archived`: Whether to backup archived events
- `backup_exclude_patterns`: File patterns to exclude
### API Endpoints
- `GET /api/admin/backup/config` - Get current configuration
- `PUT /api/admin/backup/config` - Update configuration
- `GET /api/admin/backup/status` - Get backup status and history
- `POST /api/admin/backup/run` - Trigger manual backup
- `POST /api/admin/backup/test-connection` - Test destination connectivity
### Testing
Run backup service test: `npm run test-backup`
### Database Tables
- `backup_runs`: Tracks each backup execution with statistics
- `backup_file_states`: Stores file checksums for change detection
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
+652 -631
View File
File diff suppressed because it is too large Load Diff
+117 -8
View File
@@ -28,11 +28,13 @@ Unlike expensive SaaS solutions, PicPeak gives you:
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
@@ -40,10 +42,12 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
@@ -69,14 +73,81 @@ docker-compose up -d
# Access at http://localhost:3005
```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a nonroot user by default.
- Set `PUID` and `PGID` in your `.env` to match your host users UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- 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
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## 🎯 Use Cases
Perfect for:
@@ -108,6 +179,31 @@ Perfect for:
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
@@ -177,14 +273,27 @@ Organize and manage your photo galleries with intuitive event management tools.
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
@@ -209,7 +318,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
@@ -219,6 +328,6 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
Made with ❤️ by photographers, for photographers
<br>
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT.md">Documentation</a> •
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
</p>
</p>
+546
View File
@@ -0,0 +1,546 @@
# 🚀 PicPeak Simple Setup Guide
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
## 📋 Quick Start
### One-Line Installation
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
The script will automatically detect your environment and recommend the best installation method.
## 🎯 Installation Methods
### Method 1: Docker Installation (Recommended)
Best for: Most users, easy updates, isolated environment
```bash
sudo ./picpeak-setup.sh --docker
```
**Pros:**
- ✅ Easier installation and updates
- ✅ Better isolation from system
- ✅ Consistent environment across platforms
- ✅ Built-in PostgreSQL and Redis
**Cons:**
- ❌ Requires more resources (~4GB RAM recommended)
- ❌ Additional Docker overhead
### Method 2: Native Installation
Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash
sudo ./picpeak-setup.sh --native
```
**Pros:**
- ✅ Lower resource usage (~1GB RAM minimum)
- ✅ Direct system control
- ✅ No Docker overhead
- ✅ Better for ARM devices
**Cons:**
- ❌ More complex setup
- ❌ System dependencies required
- ❌ Manual update process
## 📋 System Requirements
### Minimum Requirements
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
- **RAM**:
- Docker: 2GB minimum (4GB recommended)
- Native: 1GB minimum (2GB recommended)
- **Storage**: 2GB for application + space for photos
- **Network**: Port 3001 (or 80/443 with proxy)
### Supported Platforms
- ✅ Ubuntu 20.04, 22.04, 24.04
- ✅ Debian 11, 12
- ✅ Raspberry Pi OS (32-bit and 64-bit)
- ✅ Fedora 38, 39, 40
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
## 🛠️ Installation Options
### Interactive Mode (Default)
```bash
sudo ./picpeak-setup.sh
```
The script will prompt you to choose:
1. Installation method (Docker or Native)
2. Admin email and password
3. Domain configuration (optional)
4. Email server settings (optional)
5. SSL/HTTPS setup (optional)
### Unattended Installation
#### Docker with full configuration:
```bash
sudo ./picpeak-setup.sh --docker --unattended \
--domain photos.example.com \
--email admin@example.com \
--admin-password SecurePass123 \
--smtp-host smtp.gmail.com \
--smtp-port 587 \
--smtp-user your-email@gmail.com \
--smtp-pass your-app-password \
--enable-ssl
```
#### Native with minimal configuration:
```bash
sudo ./picpeak-setup.sh --native --unattended \
--email admin@example.com \
--admin-password SecurePass123
```
### Command Line Options
| Option | Description | Example |
|--------|-------------|---------|
| `--docker` | Use Docker installation | `--docker` |
| `--native` | Use native installation | `--native` |
| `--unattended` | Run without prompts | `--unattended` |
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
| `--email` | Admin email address | `--email admin@example.com` |
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
| `--port` | Custom port (native only) | `--port 8080` |
| `--update` | Update existing installation | `--update` |
| `--uninstall` | Remove installation | `--uninstall` |
| `--help` | Show help message | `--help` |
## 🏗️ What Gets Installed
### Docker Installation
```
~/picpeak/ # Or custom directory
├── docker-compose.yml # Service definitions
├── .env # Configuration
├── storage/
│ └── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── backup/ # Backup directory
```
**Services:**
- PicPeak Backend (Node.js application)
- PostgreSQL Database
- Redis Cache
- Nginx Reverse Proxy (optional)
- Background Workers
### Native Installation
```
/opt/picpeak/ # Installation directory
├── backend/ # Application code
├── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── config/ # Configuration files
```
**Services (systemd):**
- `picpeak-backend` - Main application
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🌐 Access Methods
### Direct Access (Simplest)
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
- Backend/API: `http://your-server:3001` (API only; no UI routes)
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
### With Domain & HTTPS
If configured during setup:
- `https://your-domain.com` - Gallery frontend
- `https://your-domain.com/admin` - Admin panel
### Behind Existing Proxy
Add to your Nginx/Apache configuration (split frontend vs backend):
```nginx
# Frontend (UI + /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 📁 Managing Galleries
### Creating a Gallery
#### Method 1: Via Admin Panel (Recommended)
1. Login to admin panel
2. Click "Create New Event"
3. Configure settings and upload photos
#### Method 2: File System
```bash
# Docker installation
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
# Native installation
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
```
### Gallery Structure
```
wedding-smith-2024/
├── collages/ # Group photos
├── individual/ # Individual photos
└── thumbnails/ # Auto-generated thumbnails
```
## 🔧 Service Management
### Docker Installation
```bash
cd ~/picpeak
# Check status
docker compose ps
# View logs
docker compose logs -f
# Stop services
docker compose down
# Start services
docker compose up -d
# Restart services
docker compose restart
# Update PicPeak
docker compose pull
docker compose up -d
```
### Native Installation
```bash
# Check status
sudo systemctl status picpeak-backend
sudo systemctl status picpeak-workers
# View logs
sudo journalctl -u picpeak-backend -f
sudo journalctl -u picpeak-workers -f
# Start services
sudo systemctl start picpeak-backend picpeak-workers
# Stop services
sudo systemctl stop picpeak-backend picpeak-workers
# Restart services
sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak
# (reruns migrations to pick up schema fixes for native installs)
sudo ./picpeak-setup.sh --update
```
## ⚙️ Configuration
### Docker Configuration
Edit `~/picpeak/.env`:
```bash
nano ~/picpeak/.env
docker compose restart
```
### Native Configuration
Edit `/opt/picpeak/app/backend/.env`:
```bash
sudo nano /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
### Key Settings
| Setting | Description | Default |
|---------|-------------|---------|
| `JWT_SECRET` | Token signing secret | Auto-generated |
| `ADMIN_EMAIL` | Admin email | admin@example.com |
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
| `PHOTOS_DIR` | Photo storage path | Varies by method |
| `SMTP_ENABLED` | Email notifications | false |
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
## 📧 Email Configuration
### Gmail Setup
1. Enable 2-Factor Authentication
2. Generate App Password
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
### SendGrid Setup
1. Sign up at sendgrid.com (100 emails/day free)
2. Create API key
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=verified-sender@yourdomain.com
```
## 🔄 Maintenance
### Backups
#### Docker:
```bash
# Backup script included
cd ~/picpeak
./backup.sh
# Manual backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
tar -czf photos-backup.tar.gz storage/events/
```
#### Native:
```bash
# Database backup
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
# Photos backup
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
```
### Updates
```bash
# Docker
cd ~/picpeak
docker compose pull
docker compose up -d
# Native
sudo ./picpeak-setup.sh --update
```
### Uninstall
```bash
# Will prompt for confirmation and data removal options
sudo ./picpeak-setup.sh --uninstall
```
## 🐛 Troubleshooting
### Common Issues
#### Service Won't Start
```bash
# Docker
docker compose logs backend
docker compose down && docker compose up -d
# Native
sudo journalctl -u picpeak-backend -n 50
sudo systemctl restart picpeak-backend
```
#### Can't Access Admin Panel
1. Check firewall:
```bash
# Ubuntu/Debian
sudo ufw allow 3001
# RHEL/CentOS
sudo firewall-cmd --add-port=3001/tcp --permanent
sudo firewall-cmd --reload
```
2. Verify service:
```bash
# Docker
curl http://localhost:3001/api/health
# Native
sudo systemctl is-active picpeak-backend
```
#### Photos Not Showing
```bash
# Check permissions (Native)
sudo chown -R picpeak:picpeak /opt/picpeak/events/
sudo chmod -R 755 /opt/picpeak/events/
# Check permissions (Docker)
ls -la ~/picpeak/storage/events/
```
#### Reset Admin Password
```bash
# Docker
docker exec picpeak-backend node scripts/reset-admin-password.js
# Native
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:**
- Docker: `docker compose logs -f`
- Native: `sudo journalctl -u picpeak-backend -f`
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://github.com/the-luap/picpeak)
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
### Essential Security
1. **Change default admin password immediately**
2. **Use HTTPS for production** (Let's Encrypt included)
3. **Configure firewall** (only open necessary ports)
4. **Regular updates** (system and PicPeak)
5. **Automated backups** (configure in admin panel)
### Advanced Security
- Use VPN for admin panel access
- Configure fail2ban for brute force protection
- Enable audit logging
- Regular security scans
- Implement IP whitelisting
## 📊 Performance Optimization
### Docker Optimization
```yaml
# Adjust in docker-compose.yml
services:
backend:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
```
### Native Optimization
```bash
# Increase Node.js memory
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
## 🎯 Quick Setup Examples
### Home/Office Network
```bash
# Simple local setup without domain
sudo ./picpeak-setup.sh --native --email admin@local.com
```
### Public Website with HTTPS
```bash
# Full production setup
sudo ./picpeak-setup.sh --docker \
--domain photos.company.com \
--email admin@company.com \
--enable-ssl
```
### Raspberry Pi Setup
```bash
# Optimized for ARM devices
sudo ./picpeak-setup.sh --native \
--port 8080 \
--email pi@local.com
```
## ✅ Post-Installation Checklist
- [ ] Admin password changed
- [ ] Email configuration tested
- [ ] First test gallery created
- [ ] Backup schedule configured
- [ ] Firewall rules applied
- [ ] SSL certificate working (if applicable)
- [ ] Monitoring setup
- [ ] Documentation bookmarked
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
+26 -7
View File
@@ -1,24 +1,43 @@
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Add build argument for cache busting
# Add build arguments
ARG CACHEBUST=1
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Install dependencies (--omit=dev replaces deprecated --only=production)
RUN npm ci --omit=dev
# Copy application files
COPY . .
# Production stage
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -29,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
# Make wait script executable
RUN chmod +x wait-for-db.sh
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
+4 -1
View File
@@ -1,7 +1,10 @@
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
@@ -0,0 +1,184 @@
const fs = require('fs');
const fsPromises = fs.promises;
const os = require('os');
const path = require('path');
const express = require('express');
const request = require('supertest');
describe('Admin settings logo upload flow', () => {
let tmpDir;
let router;
let app;
let settingsStore;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeEach(async () => {
resetModules();
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
process.env.STORAGE_PATH = tmpDir;
settingsStore = new Map();
const buildQuery = (table) => {
const filters = [];
const applyFilters = (rows) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((row) =>
filters.every(({ column, value }) => row[column] === value)
);
};
const makeRow = (row) => ({ ...row });
return {
where(column, value) {
filters.push({ column, value });
return this;
},
first() {
if (table === 'app_settings') {
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
return Promise.resolve(rows[0]);
}
return Promise.resolve(undefined);
},
select() {
return Promise.resolve([]);
},
sum() {
return Promise.resolve({ total: 0 });
},
join() {
return this;
},
groupBy() {
return this;
},
orderBy() {
return this;
},
limit() {
return this;
},
insert(payload) {
const rows = Array.isArray(payload) ? payload : [payload];
const upsert = (row, overrides = {}) => {
if (table === 'app_settings') {
const key = row.setting_key;
const existing = settingsStore.get(key) || {};
settingsStore.set(key, { ...existing, ...row, ...overrides });
}
return Promise.resolve();
};
return {
onConflict() {
return {
merge(overrides) {
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
}
};
}
};
}
};
};
const dbMock = jest.fn((table) => buildQuery(table));
dbMock.raw = jest.fn();
dbMock.transaction = async (handler) => handler({
commit: async () => {},
rollback: async () => {}
});
jest.doMock('../src/database/db', () => ({
db: dbMock,
logActivity: jest.fn()
}));
jest.doMock('../src/middleware/auth', () => ({
adminAuth: (req, res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(),
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
}));
jest.doMock('../src/services/rateLimitService', () => ({
clearSettingsCache: jest.fn()
}));
jest.doMock('../src/middleware/maintenance', () => ({
maintenanceMiddleware: (req, res, next) => next(),
clearMaintenanceCache: jest.fn()
}));
router = require('../src/routes/adminSettings');
app = express();
app.use(express.json());
app.use('/api/admin/settings', router);
});
afterEach(async () => {
resetModules();
if (tmpDir) {
await fsPromises.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
delete process.env.STORAGE_PATH;
});
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
const fileBuffer = Buffer.from('fake image data');
const uploadResponse = await request(app)
.post('/api/admin/settings/logo')
.attach('logo', fileBuffer, 'logo.png');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('logoUrl');
const logoUrl = uploadResponse.body.logoUrl;
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
await request(app)
.put('/api/admin/settings/branding')
.send({
company_name: 'Test Co',
company_tagline: 'Tagline',
support_email: 'test@example.com',
footer_text: 'Footer',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 0.5,
watermark_size: 'medium',
favicon_url: null,
logo_url: '',
watermark_logo_url: null,
logo_size: 'medium',
logo_max_height: 120,
logo_position: 'left',
logo_display_header: true,
logo_display_hero: false,
logo_display_mode: 'default'
})
.expect(200);
await expect(fsPromises.access(storedPath)).rejects.toThrow();
});
});
@@ -0,0 +1,207 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('Admin photos in reference mode', () => {
let tmpDir;
let storagePath;
let db;
let app;
let categoryId;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
storagePath = path.join(tmpDir, 'storage');
await fs.promises.mkdir(storagePath, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
try {
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
} catch (_) {
/* ignore */
}
process.env.STORAGE_PATH = storagePath;
resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
ensureThumbnail: jest.fn()
}));
jest.doMock('../../src/middleware/uploadValidation', () => ({
validateUploadedFiles: (_req, _res, next) => next()
}));
jest.doMock('../../src/utils/fileSecurityUtils', () => {
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
return {
...actual,
validateFileType: () => true,
createFileUploadValidator: () => (_req, _res, next) => next()
};
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}));
const dbModule = require('../../src/database/db');
db = dbModule.db;
await db.schema.dropTableIfExists('photo_feedback');
await db.schema.dropTableIfExists('photos');
await db.schema.dropTableIfExists('photo_categories');
await db.schema.dropTableIfExists('events');
await db.schema.createTable('events', (table) => {
table.increments('id').primary();
table.string('slug').notNullable();
table.string('event_name').notNullable();
table.string('source_mode').notNullable();
table.string('external_path');
});
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('slug').notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id');
});
await db.schema.createTable('photos', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable();
table.string('filename').notNullable();
table.string('path').notNullable();
table.string('thumbnail_path');
table.string('type').notNullable();
table.integer('size_bytes');
table.integer('category_id');
table.string('source_origin');
table.string('external_relpath');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.float('average_rating').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.integer('favorite_count').defaultTo(0);
});
await db.schema.createTable('photo_feedback', (table) => {
table.increments('id');
table.integer('photo_id');
table.string('feedback_type');
table.boolean('is_approved');
table.boolean('is_hidden');
});
await db('events').insert({
id: 1,
slug: 'test-event',
event_name: 'Test Event',
source_mode: 'reference',
external_path: 'external/library'
});
const insertedCategory = await db('photo_categories').insert({
name: 'Highlights',
slug: 'highlights',
is_global: true
});
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
const router = require('../../src/routes/adminPhotos');
app = express();
app.use(express.json());
app.use('/api/admin/events', router);
});
afterAll(async () => {
if (db) {
await db.destroy();
}
resetModules();
delete process.env.TEST_DATABASE_PATH;
delete process.env.STORAGE_PATH;
if (tmpDir) {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
});
it('stores managed uploads with category information and managed origin', async () => {
const uploadResponse = await request(app)
.post(`/api/admin/events/1/upload`)
.field('category_id', String(categoryId))
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('photos');
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
const photo = await db('photos').first();
expect(photo).toBeTruthy();
expect(photo.category_id).toBe(categoryId);
expect(photo.source_origin).toBe('managed');
expect(photo.external_relpath).toBeNull();
});
it('returns numeric category metadata when listing photos', async () => {
await db('photos').insert({
event_id: 1,
filename: 'external.jpg',
path: 'test-event/external.jpg',
thumbnail_path: null,
type: 'individual',
size_bytes: 123,
source_origin: 'external',
external_relpath: 'individual/external.jpg'
});
const response = await request(app)
.get(`/api/admin/events/1/photos`)
.expect(200);
expect(Array.isArray(response.body.photos)).toBe(true);
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
expect(managedPhoto).toBeTruthy();
expect(managedPhoto.category_name).toBe('Highlights');
const filtered = await request(app)
.get(`/api/admin/events/1/photos`)
.query({ category_id: String(categoryId) })
.expect(200);
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
});
it('normalizes category updates', async () => {
const photo = await db('photos').first();
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: '0' })
.expect(200);
const updated = await db('photos').where({ id: photo.id }).first();
expect(updated.category_id).toBeNull();
});
});
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const path = require('path');
const fs = require('fs').promises;
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
console.error('Failed to cleanup S3 objects:', error);
}
}
});
});
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs');
const path = require('path');
const crypto = require('crypto');
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
);
});
});
});
});
@@ -0,0 +1,85 @@
const path = require('path');
const mockPath = path;
jest.mock('../../src/services/externalMediaService', () => ({
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
}));
const { resolveExternalPath } = require('../../src/services/externalMediaService');
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
describe('resolvePhotoFilePath', () => {
const backendRoot = path.resolve(__dirname, '../../');
const originalStoragePath = process.env.STORAGE_PATH;
beforeEach(() => {
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
if (typeof originalStoragePath === 'string') {
process.env.STORAGE_PATH = originalStoragePath;
} else {
delete process.env.STORAGE_PATH;
}
});
it('returns absolute path for managed photos with legacy slug paths', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('normalizes prefixed managed paths without duplicating segments', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'events/active/wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('delegates external photos to external media resolver', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
});
it('deduplicates folder names when event external path already ends with segment', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
});
it('falls back to managed storage when external metadata is missing', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { path: 'fashion-show/new-upload.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).not.toHaveBeenCalled();
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
});
it('throws when external photo is missing relative path data', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external' };
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
});
});
Binary file not shown.
+4 -4
View File
@@ -1831,8 +1831,8 @@
}
},
"nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"overridden": false
},
"nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1"
},
"tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"overridden": false
},
"tunnel-agent": {
+6
View File
@@ -1,4 +1,10 @@
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
if (!process.env.SKIP_S3_TESTS) {
process.env.SKIP_S3_TESTS = 'true';
}
if (!process.env.STORAGE_PATH) {
process.env.STORAGE_PATH = '/storage';
}
});
+102 -25
View File
@@ -3,6 +3,54 @@ require('dotenv').config();
const path = require('path');
// Database configuration for different environments
const resolveSqliteFilename = (filenameEnv) => {
const fallback = path.join(__dirname, './data/photo_sharing.db');
if (!filenameEnv) {
return fallback;
}
const trimmed = String(filenameEnv).trim();
if (!trimmed) {
return fallback;
}
let resolved;
if (path.isAbsolute(trimmed)) {
resolved = trimmed;
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
resolved = path.resolve(__dirname, trimmed);
} else {
resolved = path.join(__dirname, trimmed);
}
const normalized = path.normalize(resolved);
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
if (normalized.includes(duplicatePattern)) {
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
}
return normalized;
};
const sqliteConnection = (filenameEnv) => ({
filename: resolveSqliteFilename(filenameEnv)
});
const baseSqliteConfig = {
client: 'sqlite3',
connection: sqliteConnection(),
useNullAsDefault: true,
migrations: {
directory: './migrations'
},
seeds: {
directory: './seeds'
}
};
const config = {
development: {
client: process.env.DATABASE_CLIENT || 'sqlite3',
@@ -13,7 +61,7 @@ const config = {
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
} : {
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
migrations: {
@@ -24,36 +72,65 @@ const config = {
}
},
test: (() => {
const client = process.env.DATABASE_CLIENT || 'sqlite3';
const isPostgres = client === 'pg';
return {
...baseSqliteConfig,
client,
useNullAsDefault: !isPostgres,
connection: isPostgres
? {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing_test'
}
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
};
})(),
production: {
client: process.env.DATABASE_CLIENT || 'pg',
connection: {
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
},
pool: {
min: 5,
max: 25,
acquireTimeoutMillis: 60000,
createTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
},
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
? {
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
}
: {
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
? {
min: 5,
max: 25,
acquireTimeoutMillis: 60000,
createTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
}
: undefined,
migrations: {
directory: './migrations'
},
acquireConnectionTimeout: 60000
}
};
const env = process.env.NODE_ENV || 'development';
module.exports = config[process.env.NODE_ENV || 'development'];
module.exports = config[env] || config.development;
@@ -0,0 +1,89 @@
exports.up = async function(knex) {
console.log('Running migration: 041_add_logo_customization_settings');
// Add default logo customization settings
const logoSettings = [
{
setting_key: 'branding_logo_size',
setting_value: JSON.stringify('medium'),
setting_type: 'branding',
description: 'Logo size: small, medium, large, xlarge, or custom',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_max_height',
setting_value: JSON.stringify(48),
setting_type: 'branding',
description: 'Maximum logo height in pixels (used when size is custom)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_position',
setting_value: JSON.stringify('left'),
setting_type: 'branding',
description: 'Logo position in header: left, center, right',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_header',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in gallery header',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_hero',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in hero section (for non-grid layouts)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_mode',
setting_value: JSON.stringify('logo_and_text'),
setting_type: 'branding',
description: 'Display mode: logo_only, text_only, logo_and_text',
created_at: new Date(),
updated_at: new Date()
}
];
// Insert settings that don't already exist
for (const setting of logoSettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert(setting);
console.log(`Added setting: ${setting.setting_key}`);
} else {
console.log(`Setting already exists: ${setting.setting_key}`);
}
}
console.log('Migration 041_add_logo_customization_settings completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 041_add_logo_customization_settings');
// Remove the logo customization settings
await knex('app_settings')
.whereIn('setting_key', [
'branding_logo_size',
'branding_logo_max_height',
'branding_logo_position',
'branding_logo_display_header',
'branding_logo_display_hero',
'branding_logo_display_mode'
])
.del();
console.log('Rollback of 041_add_logo_customization_settings completed');
};
+11 -4
View File
@@ -18,10 +18,15 @@ exports.up = async function(knex) {
const generatedPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
// Get admin credentials from environment or use defaults
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
await knex('admin_users').insert({
username: 'admin',
email: 'admin@example.com',
username: adminUsername,
email: adminEmail,
password_hash: passwordHash,
must_change_password: true,
created_at: new Date()
});
@@ -36,7 +41,7 @@ PicPeak Admin Credentials
Your admin account has been created with these credentials:
Username: admin
Email: ${adminEmail}
Password: ${generatedPassword}
IMPORTANT SECURITY NOTES:
@@ -47,6 +52,8 @@ IMPORTANT SECURITY NOTES:
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
Login with the email address shown above
Generated on: ${new Date().toISOString()}
========================================
`;
@@ -65,7 +72,7 @@ Generated on: ${new Date().toISOString()}
console.log('\n========================================');
console.log('✅ Admin user created successfully!');
console.log('========================================');
console.log('Username: admin');
console.log(`Email: ${adminEmail}`);
console.log(`Password: ${generatedPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. Save these credentials securely');
@@ -3,102 +3,125 @@
exports.up = async function(knex) {
console.log('Adding gallery feedback tables...');
// Check if tables and columns already exist
const hasEventFeedbackSettingsTable = await knex.schema.hasTable('event_feedback_settings');
const hasPhotoFeedbackTable = await knex.schema.hasTable('photo_feedback');
const hasFeedbackRateLimitsTable = await knex.schema.hasTable('feedback_rate_limits');
const hasFeedbackWordFiltersTable = await knex.schema.hasTable('feedback_word_filters');
const hasFeedbackCountColumn = await knex.schema.hasColumn('photos', 'feedback_count');
// Create event_feedback_settings table
await knex.schema.createTable('event_feedback_settings', (table) => {
table.increments('id').primary();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.boolean('feedback_enabled').defaultTo(false);
table.boolean('allow_ratings').defaultTo(true);
table.boolean('allow_likes').defaultTo(true);
table.boolean('allow_comments').defaultTo(false);
table.boolean('allow_favorites').defaultTo(true);
table.boolean('require_name_email').defaultTo(false);
table.boolean('moderate_comments').defaultTo(true);
table.boolean('show_feedback_to_guests').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique(['event_id']);
});
if (!hasEventFeedbackSettingsTable) {
await knex.schema.createTable('event_feedback_settings', (table) => {
table.increments('id').primary();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.boolean('feedback_enabled').defaultTo(false);
table.boolean('allow_ratings').defaultTo(true);
table.boolean('allow_likes').defaultTo(true);
table.boolean('allow_comments').defaultTo(false);
table.boolean('allow_favorites').defaultTo(true);
table.boolean('require_name_email').defaultTo(false);
table.boolean('moderate_comments').defaultTo(true);
table.boolean('show_feedback_to_guests').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique(['event_id']);
});
}
// Create photo_feedback table
await knex.schema.createTable('photo_feedback', (table) => {
table.increments('id').primary();
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.string('feedback_type', 20).notNullable();
table.integer('rating');
table.text('comment_text');
table.string('guest_name', 100);
table.string('guest_email', 255);
table.string('guest_identifier', 64);
table.string('ip_address', 45);
table.text('user_agent');
table.boolean('is_approved').defaultTo(true);
table.boolean('is_hidden').defaultTo(false);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Add indexes
table.index(['photo_id']);
table.index(['event_id']);
table.index(['feedback_type']);
table.index(['guest_identifier']);
// Add check constraint for rating (PostgreSQL)
if (knex.client.config.client === 'pg') {
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
}
});
if (!hasPhotoFeedbackTable) {
await knex.schema.createTable('photo_feedback', (table) => {
table.increments('id').primary();
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.string('feedback_type', 20).notNullable();
table.integer('rating');
table.text('comment_text');
table.string('guest_name', 100);
table.string('guest_email', 255);
table.string('guest_identifier', 64);
table.string('ip_address', 45);
table.text('user_agent');
table.boolean('is_approved').defaultTo(true);
table.boolean('is_hidden').defaultTo(false);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Add indexes
table.index(['photo_id']);
table.index(['event_id']);
table.index(['feedback_type']);
table.index(['guest_identifier']);
// Add check constraint for rating (PostgreSQL)
if (knex.client.config.client === 'pg') {
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
}
});
}
// Create feedback_rate_limits table
await knex.schema.createTable('feedback_rate_limits', (table) => {
table.increments('id').primary();
table.string('identifier', 64).notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.string('action_type', 20).notNullable();
table.integer('action_count').defaultTo(1);
table.timestamp('window_start').defaultTo(knex.fn.now());
// Add indexes
table.index(['identifier', 'event_id', 'action_type']);
table.index(['window_start']);
});
if (!hasFeedbackRateLimitsTable) {
await knex.schema.createTable('feedback_rate_limits', (table) => {
table.increments('id').primary();
table.string('identifier', 64).notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.string('action_type', 20).notNullable();
table.integer('action_count').defaultTo(1);
table.timestamp('window_start').defaultTo(knex.fn.now());
// Add indexes
table.index(['identifier', 'event_id', 'action_type']);
table.index(['window_start']);
});
}
// Create feedback_word_filters table
await knex.schema.createTable('feedback_word_filters', (table) => {
table.increments('id').primary();
table.string('word', 100).notNullable();
table.string('severity', 20).defaultTo('moderate');
table.boolean('is_active').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['word']);
});
if (!hasFeedbackWordFiltersTable) {
await knex.schema.createTable('feedback_word_filters', (table) => {
table.increments('id').primary();
table.string('word', 100).notNullable();
table.string('severity', 20).defaultTo('moderate');
table.boolean('is_active').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['word']);
});
}
// Add feedback summary columns to photos table
await knex.schema.alterTable('photos', (table) => {
table.integer('feedback_count').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.decimal('average_rating', 3, 2).defaultTo(0);
table.integer('favorite_count').defaultTo(0);
});
if (!hasFeedbackCountColumn) {
await knex.schema.alterTable('photos', (table) => {
table.integer('feedback_count').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.decimal('average_rating', 3, 2).defaultTo(0);
table.integer('favorite_count').defaultTo(0);
});
}
// Add feedback notification settings to app_settings
await knex('app_settings').insert([
{
setting_key: 'feedback_notification_email',
setting_value: JSON.stringify(''),
setting_type: 'feedback'
},
{
setting_key: 'feedback_rate_limits',
setting_value: JSON.stringify({
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 } // 200 likes per hour
}),
setting_type: 'feedback'
}
]);
const hasFeedbackNotificationEmail = await knex('app_settings')
.where('setting_key', 'feedback_notification_email')
.first();
if (!hasFeedbackNotificationEmail) {
await knex('app_settings').insert([
{
setting_key: 'feedback_notification_email',
setting_value: JSON.stringify(''),
setting_type: 'feedback'
},
{
setting_key: 'feedback_rate_limits',
setting_value: JSON.stringify({
rating: { max: 100, window: 3600 }, // 100 ratings per hour
comment: { max: 20, window: 3600 }, // 20 comments per hour
like: { max: 200, window: 3600 } // 200 likes per hour
}),
setting_type: 'feedback'
}
]);
}
console.log('Gallery feedback tables created successfully');
};
@@ -0,0 +1,87 @@
// Fix missing columns identified in GitHub issues
exports.up = async function(knex) {
console.log('Adding missing columns to database tables...');
// Add must_change_password column to admin_users table
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
if (!hasMustChangePassword) {
console.log('Adding must_change_password column to admin_users table...');
await knex.schema.table('admin_users', (table) => {
table.boolean('must_change_password').defaultTo(false);
});
}
// Add password_changed_at column to admin_users table
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
if (!hasPasswordChangedAt) {
console.log('Adding password_changed_at column to admin_users table...');
await knex.schema.table('admin_users', (table) => {
table.datetime('password_changed_at');
});
}
// Add require_moderation column to event_feedback_settings table
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
if (hasEventFeedbackSettings) {
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
if (!hasRequireModeration) {
console.log('Adding require_moderation column to event_feedback_settings table...');
await knex.schema.table('event_feedback_settings', (table) => {
table.boolean('require_moderation').defaultTo(true);
});
}
}
// Add host_name column to events table if missing
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
if (!hasHostName) {
console.log('Adding host_name column to events table...');
await knex.schema.table('events', (table) => {
table.string('host_name');
});
}
console.log('Missing columns have been added successfully');
};
exports.down = async function(knex) {
console.log('Removing added columns...');
// Remove must_change_password column from admin_users table
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
if (hasMustChangePassword) {
await knex.schema.table('admin_users', (table) => {
table.dropColumn('must_change_password');
});
}
// Remove password_changed_at column from admin_users table
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
if (hasPasswordChangedAt) {
await knex.schema.table('admin_users', (table) => {
table.dropColumn('password_changed_at');
});
}
// Remove require_moderation column from event_feedback_settings table
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
if (hasEventFeedbackSettings) {
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
if (hasRequireModeration) {
await knex.schema.table('event_feedback_settings', (table) => {
table.dropColumn('require_moderation');
});
}
}
// Remove host_name column from events table
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
if (hasHostName) {
await knex.schema.table('events', (table) => {
table.dropColumn('host_name');
});
}
console.log('Columns removed');
};
@@ -0,0 +1,69 @@
// Add download control features to events table
exports.up = async function(knex) {
console.log('Adding download control columns to events table...');
// Add download control columns to events table
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
if (!hasAllowDownloads) {
await knex.schema.table('events', (table) => {
table.boolean('allow_downloads').defaultTo(true);
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
});
}
// Add download control settings to app_settings
const downloadSettingExists = await knex('app_settings')
.where('setting_key', 'default_allow_downloads')
.first();
if (!downloadSettingExists) {
await knex('app_settings').insert([
{
setting_key: 'default_allow_downloads',
setting_value: JSON.stringify(true),
setting_type: 'gallery'
},
{
setting_key: 'default_disable_right_click',
setting_value: JSON.stringify(false),
setting_type: 'gallery'
},
{
setting_key: 'default_watermark_downloads',
setting_value: JSON.stringify(false),
setting_type: 'gallery'
}
]);
}
console.log('Download control features added successfully');
};
exports.down = async function(knex) {
console.log('Removing download control columns...');
// Remove app settings
await knex('app_settings')
.whereIn('setting_key', [
'default_allow_downloads',
'default_disable_right_click',
'default_watermark_downloads'
])
.delete();
// Remove columns from events table
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
if (hasAllowDownloads) {
await knex.schema.table('events', (table) => {
table.dropColumn('allow_downloads');
table.dropColumn('disable_right_click');
table.dropColumn('watermark_downloads');
table.dropColumn('watermark_text');
});
}
console.log('Download control columns removed');
};
@@ -0,0 +1,128 @@
// Add enhanced image protection features
exports.up = async function(knex) {
console.log('Adding enhanced image protection features...');
// Add protection columns to events table
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
if (!hasProtectionLevel) {
await knex.schema.table('events', (table) => {
table.enum('protection_level', ['basic', 'standard', 'enhanced', 'maximum']).defaultTo('standard');
table.integer('image_quality').defaultTo(85);
table.boolean('add_fingerprint').defaultTo(true);
table.boolean('enable_devtools_protection').defaultTo(true);
table.boolean('use_canvas_rendering').defaultTo(false);
table.integer('fragmentation_level').defaultTo(3);
table.boolean('overlay_protection').defaultTo(true);
});
}
// Create image access logs table
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
if (!hasImageAccessLogs) {
await knex.schema.createTable('image_access_logs', (table) => {
table.increments('id').primary();
table.integer('photo_id').unsigned().notNullable();
table.integer('event_id').unsigned().notNullable();
table.string('client_ip', 45).notNullable();
table.text('user_agent');
table.string('access_type', 20).defaultTo('view'); // view, download, suspicious
table.string('client_fingerprint', 32).notNullable();
table.timestamp('accessed_at').defaultTo(knex.fn.now());
table.json('metadata'); // Additional security metadata
table.foreign('photo_id').references('id').inTable('photos').onDelete('CASCADE');
table.foreign('event_id').references('id').inTable('events').onDelete('CASCADE');
table.index(['photo_id', 'accessed_at']);
table.index(['client_fingerprint', 'accessed_at']);
table.index(['client_ip', 'accessed_at']);
});
}
// Add protection settings to app_settings
const protectionSettingExists = await knex('app_settings')
.where('setting_key', 'default_protection_level')
.first();
if (!protectionSettingExists) {
await knex('app_settings').insert([
{
setting_key: 'default_protection_level',
setting_value: JSON.stringify('standard'),
setting_type: 'security'
},
{
setting_key: 'default_image_quality',
setting_value: JSON.stringify(85),
setting_type: 'security'
},
{
setting_key: 'enable_devtools_protection',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'max_image_requests_per_minute',
setting_value: JSON.stringify(30),
setting_type: 'security'
},
{
setting_key: 'suspicious_activity_threshold',
setting_value: JSON.stringify(10),
setting_type: 'security'
},
{
setting_key: 'enable_canvas_rendering',
setting_value: JSON.stringify(false),
setting_type: 'security'
},
{
setting_key: 'default_fragmentation_level',
setting_value: JSON.stringify(3),
setting_type: 'security'
}
]);
}
console.log('Enhanced image protection features added successfully');
};
exports.down = async function(knex) {
console.log('Removing enhanced image protection features...');
// Remove app settings
await knex('app_settings')
.whereIn('setting_key', [
'default_protection_level',
'default_image_quality',
'enable_devtools_protection',
'max_image_requests_per_minute',
'suspicious_activity_threshold',
'enable_canvas_rendering',
'default_fragmentation_level'
])
.delete();
// Drop image access logs table
const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs');
if (hasImageAccessLogs) {
await knex.schema.dropTable('image_access_logs');
}
// Remove protection columns from events table
const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level');
if (hasProtectionLevel) {
await knex.schema.table('events', (table) => {
table.dropColumn('protection_level');
table.dropColumn('image_quality');
table.dropColumn('add_fingerprint');
table.dropColumn('enable_devtools_protection');
table.dropColumn('use_canvas_rendering');
table.dropColumn('fragmentation_level');
table.dropColumn('overlay_protection');
});
}
console.log('Enhanced image protection features removed');
};
@@ -0,0 +1,117 @@
// Add security logging and monitoring tables
exports.up = async function(knex) {
console.log('Adding security logging and monitoring tables...');
// Create security logs table for general security events
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
if (!hasSecurityLogs) {
await knex.schema.createTable('security_logs', (table) => {
table.increments('id').primary();
table.string('event_type', 50).notNullable(); // rate_limit_exceeded, suspicious_activity, etc.
table.string('client_ip', 45).notNullable();
table.string('client_fingerprint', 32);
table.text('user_agent');
table.string('request_path');
table.string('request_method', 10);
table.json('details'); // Additional event details
table.timestamp('timestamp').defaultTo(knex.fn.now());
// Indexes for performance
table.index(['event_type', 'timestamp']);
table.index(['client_ip', 'timestamp']);
table.index(['client_fingerprint', 'timestamp']);
});
}
// Add security monitoring settings to app_settings
const securitySettings = [
{
setting_key: 'security_monitoring_enabled',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'max_image_requests_per_5_minutes',
setting_value: JSON.stringify(100),
setting_type: 'security'
},
{
setting_key: 'max_image_requests_per_hour',
setting_value: JSON.stringify(500),
setting_type: 'security'
},
{
setting_key: 'block_suspicious_ips',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'log_security_events_to_db',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'auto_block_threshold',
setting_value: JSON.stringify(5),
setting_type: 'security'
}
];
for (const setting of securitySettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert(setting);
}
}
// Add mime_type column to photos table if it doesn't exist
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
if (!hasMimeType) {
await knex.schema.table('photos', (table) => {
table.string('mime_type', 100);
});
// Update existing photos with default mime type
await knex('photos')
.whereNull('mime_type')
.update({ mime_type: 'image/jpeg' });
}
console.log('Security logging and monitoring tables added successfully');
};
exports.down = async function(knex) {
console.log('Removing security logging and monitoring tables...');
// Remove security settings
await knex('app_settings')
.whereIn('setting_key', [
'security_monitoring_enabled',
'max_image_requests_per_5_minutes',
'max_image_requests_per_hour',
'block_suspicious_ips',
'log_security_events_to_db',
'auto_block_threshold'
])
.delete();
// Drop security logs table
const hasSecurityLogs = await knex.schema.hasTable('security_logs');
if (hasSecurityLogs) {
await knex.schema.dropTable('security_logs');
}
// Remove mime_type column from photos table
const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type');
if (hasMimeType) {
await knex.schema.table('photos', (table) => {
table.dropColumn('mime_type');
});
}
console.log('Security logging and monitoring tables removed');
};
@@ -0,0 +1,33 @@
exports.up = async function(knex) {
// Add thumbnail settings to app_settings table
const thumbnailSettings = [
{ setting_key: 'thumbnail_width', setting_value: 300, setting_type: 'number' },
{ setting_key: 'thumbnail_height', setting_value: 300, setting_type: 'number' },
{ setting_key: 'thumbnail_fit', setting_value: JSON.stringify('cover'), setting_type: 'string' },
{ setting_key: 'thumbnail_quality', setting_value: 85, setting_type: 'number' },
{ setting_key: 'thumbnail_format', setting_value: JSON.stringify('jpeg'), setting_type: 'string' }
];
for (const setting of thumbnailSettings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = async function(knex) {
// Remove thumbnail settings
await knex('app_settings')
.whereIn('setting_key', [
'thumbnail_width',
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format'
])
.del();
};
@@ -0,0 +1,54 @@
/**
* Migration 041: Add external media reference support
* - events.source_mode: 'managed' | 'reference'
* - events.external_path: relative path under external media root
* - photos.source_origin: 'managed' | 'external'
* - photos.external_relpath: relative path within event.external_path
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 041_add_external_media');
// events.source_mode (default 'managed')
await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => {
table.string('source_mode').notNullable().defaultTo('managed');
});
// events.external_path (nullable)
await addColumnIfNotExists(knex, 'events', 'external_path', (table) => {
table.text('external_path');
});
// photos.source_origin (default 'managed')
await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => {
table.string('source_origin').notNullable().defaultTo('managed');
});
// photos.external_relpath (nullable)
await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => {
table.text('external_relpath');
});
// Helpful index for queries
try {
if (knex.client.config.client === 'pg') {
await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)");
} else {
await knex.schema.alterTable('photos', (table) => {
table.index(['event_id', 'source_origin'], 'photos_event_source_idx');
});
}
} catch (e) {
console.log('Index creation skipped or failed (may already exist):', e.message);
}
console.log('Migration 041_add_external_media completed');
};
exports.down = async function(knex) {
console.log('Rollback: 041_add_external_media');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,51 @@
const logger = require('../../src/utils/logger');
async function ensureColumn(knex, tableName, columnName, alterFn) {
const exists = await knex.schema.hasColumn(tableName, columnName);
if (!exists) {
logger.info(`Adding column ${tableName}.${columnName}`);
await knex.schema.table(tableName, alterFn);
}
}
exports.up = async function(knex) {
await ensureColumn(knex, 'events', 'host_name', (table) => {
table.string('host_name');
});
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
table.boolean('allow_user_uploads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
table.integer('upload_category_id');
});
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
table.boolean('allow_downloads').defaultTo(true);
});
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
table.boolean('disable_right_click').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
table.boolean('watermark_downloads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
table.text('watermark_text');
});
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
table.string('uploaded_by').defaultTo('admin');
});
};
exports.down = async function() {
// Non destructive migration; no rollback
};
@@ -0,0 +1,46 @@
const {
DEFAULT_PUBLIC_SITE_HTML,
} = require('../../src/constants/publicSiteDefaults');
exports.up = async function(knex) {
const defaults = [
{
setting_key: 'general_public_site_enabled',
setting_value: JSON.stringify(false),
setting_type: 'general'
},
{
setting_key: 'general_public_site_html',
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
setting_type: 'general'
},
{
setting_key: 'general_public_site_custom_css',
setting_value: JSON.stringify(''),
setting_type: 'general'
}
];
for (const setting of defaults) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = async function(knex) {
await knex('app_settings')
.whereIn('setting_key', [
'general_public_site_enabled',
'general_public_site_html',
'general_public_site_custom_css'
])
.del();
};
@@ -0,0 +1,18 @@
exports.up = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (!hasColumn) {
await knex.schema.table('events', (table) => {
table.boolean('require_password').notNullable().defaultTo(true);
});
await knex('events').update({ require_password: true });
}
};
exports.down = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (hasColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('require_password');
});
}
};
@@ -0,0 +1,48 @@
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
exports.up = async function up(knex) {
const settingKey = 'general_max_files_per_upload';
const existing = await knex('app_settings')
.where({ setting_key: settingKey })
.first();
if (existing) {
// Normalize existing value into allowed bounds
let parsedValue;
try {
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
} catch {
parsedValue = existing.setting_value;
}
const numeric = Number(parsedValue);
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
if (Number.isFinite(numeric) && numeric >= 1) {
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
}
if (normalized !== numeric) {
await knex('app_settings')
.where({ setting_key: settingKey })
.update({
setting_value: JSON.stringify(normalized),
updated_at: new Date()
});
}
return;
}
await knex('app_settings').insert({
setting_key: settingKey,
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
setting_type: 'general',
updated_at: new Date()
});
};
exports.down = async function down(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.del();
};
@@ -0,0 +1,44 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
table.string('customer_name');
});
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
table.string('customer_email');
});
// Backfill new columns from legacy host_* fields
const client = knex?.client?.config?.client;
if (client === 'pg') {
await knex.raw(`
UPDATE events
SET customer_name = COALESCE(customer_name, host_name),
customer_email = COALESCE(customer_email, host_email)
`);
} else {
// SQLite fallback
await knex('events').update({
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
customer_email: knex.raw('COALESCE(customer_email, host_email)')
});
}
};
exports.down = async function down(knex) {
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
if (hasCustomerName) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_name');
});
}
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
if (hasCustomerEmail) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_email');
});
}
};
@@ -0,0 +1,18 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
// Add tls_reject_unauthorized column to email_configs table
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
table.boolean('tls_reject_unauthorized').defaultTo(true);
});
};
exports.down = async function down(knex) {
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
if (hasColumn) {
await knex.schema.alterTable('email_configs', (table) => {
table.dropColumn('tls_reject_unauthorized');
});
}
};
@@ -0,0 +1,109 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* Migration: Add video support to photos table
* - Adds columns for video metadata (media_type, duration, codecs, dimensions)
* - Updates existing photos to have media_type 'image'
*/
exports.up = async function(knex) {
console.log('Running migration: 042_add_video_support');
// Add media_type column (image or video)
await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => {
table.string('media_type').defaultTo('image');
});
// Add mime_type column if not exists
await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => {
table.string('mime_type');
});
// Add duration column (for videos, in seconds)
await addColumnIfNotExists(knex, 'photos', 'duration', (table) => {
table.integer('duration');
});
// Add video codec information
await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => {
table.string('video_codec');
});
// Add audio codec information
await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => {
table.string('audio_codec');
});
// Add width dimension
await addColumnIfNotExists(knex, 'photos', 'width', (table) => {
table.integer('width');
});
// Add height dimension
await addColumnIfNotExists(knex, 'photos', 'height', (table) => {
table.integer('height');
});
// Update existing photos to have media_type 'image' if not set
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex('photos')
.whereNull('media_type')
.orWhere('media_type', '')
.update({ media_type: 'image' });
console.log('Updated existing photos to have media_type "image"');
}
console.log('Migration 042_add_video_support completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 042_add_video_support');
// Remove video support columns
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('media_type');
});
}
const hasDuration = await knex.schema.hasColumn('photos', 'duration');
if (hasDuration) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('duration');
});
}
const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec');
if (hasVideoCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('video_codec');
});
}
const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec');
if (hasAudioCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('audio_codec');
});
}
const hasWidth = await knex.schema.hasColumn('photos', 'width');
if (hasWidth) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('width');
});
}
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (hasHeight) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('height');
});
}
// Note: We don't drop mime_type as it may be used by images as well
console.log('Rollback of 042_add_video_support completed');
};
@@ -0,0 +1,21 @@
/**
* Migration: Add slug_redirects table for event rename feature
* This table stores old slugs that should redirect to new slugs
*/
exports.up = function(knex) {
return knex.schema.createTable('slug_redirects', (table) => {
table.increments('id').primary();
table.string('old_slug', 255).notNullable().unique();
table.string('new_slug', 255).notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Index for fast lookup
table.index('old_slug');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('slug_redirects');
};
@@ -0,0 +1,33 @@
/**
* Migration: Add optional event fields settings
* These settings control whether customer name, customer email, and admin email
* are required when creating new events.
*/
exports.up = async function(knex) {
const settings = [
{ setting_key: 'event_require_customer_name', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_customer_email', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_admin_email', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = function(knex) {
return knex('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
])
.del();
};
@@ -0,0 +1,100 @@
/**
* Migration: Add indexes for photo filtering performance
* These indexes optimize queries that filter by feedback metrics
*/
exports.up = async function(knex) {
// Add comment_count column if it doesn't exist
const hasCommentCount = await knex.schema.hasColumn('photos', 'comment_count');
if (!hasCommentCount) {
await knex.schema.alterTable('photos', (table) => {
table.integer('comment_count').defaultTo(0);
});
}
// Add indexes for common filter queries
// Note: PostgreSQL supports partial indexes, SQLite does not
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// Partial indexes for PostgreSQL
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
WHERE average_rating > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
WHERE like_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
WHERE favorite_count > 0
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
WHERE comment_count > 0
`);
} else {
// Regular indexes for SQLite
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_rating_filter
ON photos(event_id, average_rating)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_likes_filter
ON photos(event_id, like_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_favorites_filter
ON photos(event_id, favorite_count)
`);
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_photos_comments_filter
ON photos(event_id, comment_count)
`);
}
// Create export_jobs table for tracking large exports
const hasExportJobs = await knex.schema.hasTable('export_jobs');
if (!hasExportJobs) {
await knex.schema.createTable('export_jobs', (table) => {
table.increments('id').primary();
table.string('job_id', 50).unique().notNullable();
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.integer('admin_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
table.string('format', 20).notNullable();
table.string('status', 20).defaultTo('pending');
table.integer('progress').defaultTo(0);
table.integer('total_photos');
table.json('options');
table.string('file_path', 500);
table.bigInteger('file_size');
table.text('error_message');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('completed_at');
});
}
};
exports.down = async function(knex) {
// Drop indexes
await knex.raw('DROP INDEX IF EXISTS idx_photos_rating_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_likes_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_favorites_filter');
await knex.raw('DROP INDEX IF EXISTS idx_photos_comments_filter');
// Drop export_jobs table
await knex.schema.dropTableIfExists('export_jobs');
// Note: We don't remove comment_count column as it might have data
};
@@ -0,0 +1,192 @@
/**
* Migration: Add CSS Templates feature
* Creates css_templates table and adds css_template_id to events table
*/
// Default CSS template content
const DEFAULT_CSS_TEMPLATE = `/*
* PicPeak Custom CSS Template: Elegant Dark
*
* Available CSS Custom Properties:
* --gallery-bg: Background color
* --gallery-text: Primary text color
* --gallery-accent: Accent/highlight color
* --gallery-border: Border color
* --gallery-shadow: Box shadow value
* --gallery-radius: Border radius value
* --gallery-spacing: Base spacing unit
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--gallery-bg: #1a1a2e;
--gallery-bg-secondary: #16213e;
--gallery-text: #eaeaea;
--gallery-text-muted: #8b8b9a;
--gallery-accent: #e94560;
--gallery-accent-hover: #ff6b6b;
--gallery-border: #2d2d44;
--gallery-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--gallery-radius: 12px;
--gallery-spacing: 16px;
}
/* ===== Page Background ===== */
.gallery-page {
background: linear-gradient(135deg, var(--gallery-bg) 0%, var(--gallery-bg-secondary) 100%);
min-height: 100vh;
color: var(--gallery-text);
}
/* ===== Gallery Header ===== */
.gallery-header {
background: rgba(22, 33, 62, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--gallery-border);
padding: calc(var(--gallery-spacing) * 2);
}
.gallery-title {
color: var(--gallery-text);
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
}
/* ===== Photo Cards ===== */
.photo-card {
background: var(--gallery-bg-secondary);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border: 1px solid var(--gallery-border);
}
.photo-card:hover {
transform: translateY(-4px);
box-shadow: var(--gallery-shadow);
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
.photo-card:hover img {
transform: scale(1.05);
}
/* ===== Buttons ===== */
.gallery-btn {
background: var(--gallery-accent);
color: white;
border: none;
border-radius: calc(var(--gallery-radius) / 2);
padding: calc(var(--gallery-spacing) / 2) var(--gallery-spacing);
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.gallery-btn:hover {
background: var(--gallery-accent-hover);
transform: translateY(-2px);
}
/* ===== Lightbox ===== */
.lightbox-overlay {
background: rgba(10, 10, 20, 0.95);
backdrop-filter: blur(20px);
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-spacing: 12px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.gallery-title {
font-size: 1.5rem;
}
}`;
exports.up = async function(knex) {
// Create css_templates table
const hasTable = await knex.schema.hasTable('css_templates');
if (!hasTable) {
await knex.schema.createTable('css_templates', (table) => {
table.increments('id').primary();
table.integer('slot_number').notNullable();
table.string('name', 50).notNullable().defaultTo('Untitled');
table.text('css_content').notNullable().defaultTo('');
table.boolean('is_enabled').notNullable().defaultTo(false);
table.boolean('is_default').notNullable().defaultTo(false);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.unique('slot_number');
});
// Insert default templates
await knex('css_templates').insert([
{
slot_number: 1,
name: 'Elegant Dark',
css_content: DEFAULT_CSS_TEMPLATE,
is_enabled: true,
is_default: true
},
{
slot_number: 2,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
},
{
slot_number: 3,
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false
}
]);
}
// Add css_template_id to events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.integer('css_template_id').references('id').inTable('css_templates').onDelete('SET NULL');
});
}
};
exports.down = async function(knex) {
// Remove css_template_id from events table
const hasColumn = await knex.schema.hasColumn('events', 'css_template_id');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('css_template_id');
});
}
// Drop css_templates table
await knex.schema.dropTableIfExists('css_templates');
};
// Export default template for use in reset functionality
module.exports.DEFAULT_CSS_TEMPLATE = DEFAULT_CSS_TEMPLATE;
@@ -0,0 +1,731 @@
/**
* Migration: Add Liquid Glass CSS Templates
* Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs
*
* These are starter example templates for new installations.
* Users can edit or replace them as needed.
*/
const APPLE_LIQUID_GLASS = `/*
* PicPeak Custom CSS Template: Apple Liquid Glass
* Authentic iOS 26 / macOS Tahoe Liquid Glass Design
*/
/* ===== Apple System Fonts ===== */
.gallery-page,
.gallery-page *,
.gallery-sidebar,
.gallery-sidebar * {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===== CSS Variables ===== */
:root {
--glass-blur: 20px;
--glass-blur-heavy: 40px;
--glass-saturation: 180%;
--glass-bg: rgba(255, 255, 255, 0.08);
--glass-bg-medium: rgba(255, 255, 255, 0.18);
--glass-bg-solid: rgba(255, 255, 255, 0.25);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-border-light: rgba(255, 255, 255, 0.4);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15);
--glass-inset: inset 0 1px 1px rgba(255, 255, 255, 0.4),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
--gallery-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
--gallery-text: #1a1a2e;
--gallery-text-light: #ffffff;
--gallery-accent: #667eea;
--gallery-radius: 20px;
--gallery-radius-sm: 12px;
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-gradient) !important;
background-attachment: fixed !important;
min-height: 100vh;
}
.gallery-page::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 600px 400px at 15% 85%, rgba(255, 255, 255, 0.2) 0%, transparent 50%),
radial-gradient(ellipse 500px 350px at 85% 15%, rgba(255, 255, 255, 0.15) 0%, transparent 45%);
pointer-events: none;
z-index: 0;
}
/* ===== TOP BAR / HEADER - Liquid Glass ===== */
.gallery-page .gallery-header,
.gallery-page header {
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-bottom: 1px solid var(--glass-border) !important;
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
}
.gallery-page .gallery-header > div {
background: transparent !important;
border: none !important;
}
/* ===== SIDEBAR - Liquid Glass ===== */
.gallery-sidebar {
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(var(--glass-saturation)) !important;
border-right: 1px solid var(--glass-border) !important;
box-shadow: 4px 0 32px rgba(31, 38, 135, 0.1), var(--glass-inset) !important;
}
.gallery-sidebar h2,
.gallery-sidebar h3 {
color: var(--gallery-text) !important;
font-weight: 600 !important;
}
/* ===== HERO LAYOUT - Transform to Glass Title Box ===== */
/* Target the hero wrapper */
.gallery-page .relative.-mt-6 {
margin-top: 0 !important;
}
/* Target the hero section (first child with h-[60vh]) */
.gallery-page .relative.-mt-6 > .relative:first-child {
height: auto !important;
min-height: auto !important;
margin: 0 !important;
padding: 2rem !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
background: transparent !important;
}
/* Hide the hero background image */
.gallery-page .relative.-mt-6 > .relative:first-child > img,
.gallery-page .relative.-mt-6 > .relative:first-child > canvas {
display: none !important;
}
/* Hide the dark overlay */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.bg-black {
display: none !important;
}
/* Style the content area as glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
position: relative !important;
inset: auto !important;
background: var(--glass-bg-medium) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border-light) !important;
border-radius: var(--gallery-radius) !important;
padding: 2rem 3rem !important;
box-shadow: var(--glass-shadow), var(--glass-inset) !important;
max-width: 600px !important;
width: auto !important;
}
/* Hide logo in glass title box */
.gallery-page .relative.-mt-6 > .relative:first-child .mb-6 {
display: none !important;
}
/* Style title text */
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
color: var(--gallery-text) !important;
text-shadow: none !important;
font-weight: 700 !important;
font-size: 2.25rem !important;
margin-bottom: 0.75rem !important;
}
/* Style date text */
.gallery-page .relative.-mt-6 > .relative:first-child .text-white\\/90 {
color: var(--gallery-text) !important;
opacity: 0.8;
}
/* Hide scroll down button */
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.bottom-8,
.gallery-page .relative.-mt-6 > .relative:first-child > button.absolute {
display: none !important;
}
/* ===== PHOTO GRID ===== */
.gallery-page .photo-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) !important;
gap: 1.25rem !important;
padding: 1rem !important;
}
/* ===== PHOTO CARDS - Liquid Glass ===== */
.gallery-page .photo-card {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius) !important;
overflow: hidden !important;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), var(--glass-inset) !important;
}
.gallery-page .photo-card:hover {
transform: translateY(-6px) scale(1.02) !important;
box-shadow: 0 20px 40px rgba(102, 126, 234, 0.25),
0 8px 16px rgba(0, 0, 0, 0.1),
var(--glass-inset) !important;
border-color: var(--glass-border-light) !important;
}
.gallery-page .photo-card img {
transition: transform 0.4s ease !important;
}
.gallery-page .photo-card:hover img {
transform: scale(1.05) !important;
}
/* ===== BUTTONS - Glass Pill Style ===== */
.gallery-page button,
.gallery-page [role="button"],
.gallery-sidebar button {
background: var(--glass-bg) !important;
backdrop-filter: blur(12px) saturate(150%) !important;
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
border: 1px solid var(--glass-border) !important;
border-radius: 9999px !important;
color: var(--gallery-text) !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
.gallery-page button:hover,
.gallery-page [role="button"]:hover,
.gallery-sidebar button:hover {
background: var(--glass-bg-medium) !important;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
.gallery-page button[class*="bg-primary"],
.gallery-page .gallery-btn-download {
background: linear-gradient(135deg, var(--gallery-accent) 0%, #764ba2 100%) !important;
color: white !important;
border: none !important;
}
/* ===== INPUT FIELDS ===== */
.gallery-page input,
.gallery-page select,
.gallery-sidebar input,
.gallery-sidebar select {
background: rgba(255, 255, 255, 0.25) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border: 1px solid var(--glass-border) !important;
border-radius: var(--gallery-radius-sm) !important;
color: var(--gallery-text) !important;
}
/* Input placeholder text - make it visible */
.gallery-page input::placeholder,
.gallery-sidebar input::placeholder {
color: rgba(26, 26, 46, 0.6) !important;
opacity: 1 !important;
}
/* Input focus state */
.gallery-page input:focus,
.gallery-sidebar input:focus {
background: rgba(255, 255, 255, 0.35) !important;
border-color: var(--glass-border-light) !important;
outline: none !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
}
/* ===== FOOTER ===== */
.gallery-page .gallery-footer,
.gallery-page footer {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)) !important;
border-top: 1px solid var(--glass-border) !important;
}
/* ===== LIGHTBOX ===== */
.gallery-page [class*="fixed"][class*="inset-0"][class*="z-50"] {
background: rgba(0, 0, 0, 0.7) !important;
backdrop-filter: blur(30px) !important;
-webkit-backdrop-filter: blur(30px) !important;
}
/* ===== SCROLLBAR ===== */
.gallery-page ::-webkit-scrollbar,
.gallery-sidebar ::-webkit-scrollbar {
width: 8px;
}
.gallery-page ::-webkit-scrollbar-track,
.gallery-sidebar ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.gallery-page ::-webkit-scrollbar-thumb,
.gallery-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
:root {
--gallery-radius: 16px;
--glass-blur: 16px;
}
.gallery-page .relative.-mt-6 > .relative:first-child > .absolute.inset-0.flex {
padding: 1.5rem 2rem !important;
max-width: 90% !important;
}
.gallery-page .relative.-mt-6 > .relative:first-child h1 {
font-size: 1.5rem !important;
}
.gallery-page .photo-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.75rem !important;
}
}
/* ===== ACCESSIBILITY ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page .photo-card,
.gallery-page button {
transition: none !important;
}
.gallery-page .photo-card:hover {
transform: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.gallery-page .photo-card,
.gallery-page button,
.gallery-sidebar {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
background: rgba(255, 255, 255, 0.95) !important;
}
}`;
const LIQUID_GLASS_DARK = `/*
* PicPeak Custom CSS Template: Liquid Glass Dark
* Inspired by Apple's iOS 26 Liquid Glass Design Language
*
* Features:
* - Deep translucent dark surfaces
* - Neon accent highlights
* - Dramatic glass reflections
* - Subtle animated gradients
*/
/* ===== Base Theme Variables ===== */
.gallery-page {
--glass-bg: rgba(15, 15, 35, 0.7);
--glass-bg-elevated: rgba(25, 25, 55, 0.85);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-highlight: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--glass-blur: 24px;
--glass-saturation: 150%;
--gallery-bg: #0a0a1a;
--gallery-text: #f0f0f5;
--gallery-text-muted: rgba(240, 240, 245, 0.6);
--gallery-accent: #00d4ff;
--gallery-accent-secondary: #ff00e5;
--gallery-accent-hover: #00ffea;
--gallery-radius: 20px;
--gallery-spacing: 20px;
/* Neon glow variables */
--neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2);
--neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2);
}
/* ===== Page Background ===== */
.gallery-page {
background: var(--gallery-bg);
min-height: 100vh;
position: relative;
overflow-x: hidden;
}
/* Animated mesh gradient background */
.gallery-page::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background:
radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%),
radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%),
radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%);
animation: gradientShift 20s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gradientShift {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(2%, 2%) rotate(1deg); }
50% { transform: translate(-1%, 3%) rotate(-1deg); }
75% { transform: translate(3%, -2%) rotate(2deg); }
}
/* ===== Gallery Header ===== */
.gallery-header {
background: var(--glass-bg-elevated);
backdrop-filter: blur(30px) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation));
border-bottom: 1px solid var(--glass-border-highlight);
padding: calc(var(--gallery-spacing) * 1.5);
position: sticky;
top: 0;
z-index: 100;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.gallery-title {
color: var(--gallery-text);
font-weight: 700;
font-size: 1.75rem;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===== Photo Grid ===== */
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--gallery-spacing);
padding: calc(var(--gallery-spacing) * 2);
position: relative;
z-index: 1;
}
/* ===== Photo Cards - Dark Glass Style ===== */
.photo-card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
border: 1px solid var(--glass-border);
border-radius: var(--gallery-radius);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Top highlight reflection */
.photo-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
z-index: 2;
}
/* Inner glow effect */
.photo-card::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--gallery-radius);
padding: 1px;
background: linear-gradient(
135deg,
rgba(0, 212, 255, 0) 0%,
rgba(0, 212, 255, 0) 40%,
rgba(0, 212, 255, 0.1) 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease;
}
.photo-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: var(--glass-border-highlight);
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(0, 212, 255, 0.2),
var(--neon-glow);
}
.photo-card:hover::after {
opacity: 1;
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
}
.photo-card:hover img {
transform: scale(1.05);
filter: brightness(1);
}
.photo-card-info {
padding: var(--gallery-spacing);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.4) 100%
);
color: var(--gallery-text);
}
.photo-card-info p {
color: var(--gallery-text-muted);
font-size: 0.875rem;
}
/* ===== Buttons - Neon Glass Style ===== */
.gallery-btn {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: calc(var(--gallery-radius) / 2);
padding: 12px 24px;
color: var(--gallery-text);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.gallery-btn:hover {
border-color: var(--gallery-accent);
box-shadow: var(--neon-glow);
color: var(--gallery-accent);
}
.gallery-btn-primary {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border: none;
box-shadow: var(--neon-glow);
}
.gallery-btn-primary:hover {
box-shadow:
0 0 30px rgba(0, 212, 255, 0.6),
0 0 60px rgba(0, 212, 255, 0.3),
0 0 90px rgba(255, 0, 229, 0.2);
transform: translateY(-2px);
}
/* ===== Lightbox - Dark Glass ===== */
.lightbox-overlay {
background: rgba(5, 5, 15, 0.9);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.lightbox-content {
background: var(--glass-bg-elevated);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border-highlight);
border-radius: var(--gallery-radius);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.5),
var(--neon-glow);
}
/* ===== Category Pills ===== */
.category-pill {
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
border-radius: 9999px;
padding: 8px 20px;
font-size: 0.875rem;
font-weight: 500;
color: var(--gallery-text-muted);
transition: all 0.3s ease;
}
.category-pill:hover {
border-color: var(--gallery-accent);
color: var(--gallery-accent);
box-shadow: var(--neon-glow);
}
.category-pill.active {
background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
color: white;
border-color: transparent;
box-shadow: var(--neon-glow);
}
/* ===== Scrollbar Styling ===== */
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gallery-page ::-webkit-scrollbar-track {
background: var(--glass-bg);
border-radius: 4px;
}
.gallery-page ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%);
border-radius: 4px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.gallery-page {
--gallery-radius: 16px;
--gallery-spacing: 12px;
--glass-blur: 16px;
}
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.photo-card img {
height: 180px;
}
/* Reduce animation complexity on mobile */
.gallery-page::before {
animation: none;
}
}
/* ===== Accessibility: Reduce Motion ===== */
@media (prefers-reduced-motion: reduce) {
.gallery-page::before {
animation: none;
}
.photo-card,
.gallery-btn {
transition: none;
}
.photo-card:hover {
transform: none;
}
}
/* ===== Accessibility: Reduce Transparency ===== */
@media (prefers-reduced-transparency: reduce) {
.photo-card,
.gallery-btn,
.gallery-header {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.gallery-page {
--glass-bg: rgba(20, 20, 40, 0.98);
--glass-bg-elevated: rgba(30, 30, 60, 0.98);
}
}`;
exports.up = async function(knex) {
// Update template slot 2 with Apple Liquid Glass (Light)
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Apple Liquid Glass',
css_content: APPLE_LIQUID_GLASS,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
// Update template slot 3 with Liquid Glass Dark
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Liquid Glass Dark',
css_content: LIQUID_GLASS_DARK,
is_enabled: true,
is_default: false,
updated_at: knex.fn.now()
});
};
exports.down = async function(knex) {
// Revert to empty templates
await knex('css_templates')
.where({ slot_number: 2 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
await knex('css_templates')
.where({ slot_number: 3 })
.update({
name: 'Untitled',
css_content: '',
is_enabled: false,
is_default: false,
updated_at: knex.fn.now()
});
};
// Export templates for use elsewhere
module.exports.APPLE_LIQUID_GLASS = APPLE_LIQUID_GLASS;
module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK;
@@ -0,0 +1,91 @@
/**
* Migration: Add Roles Table
* Creates the roles table for RBAC multi-administrator support.
*
* Default roles:
* - super_admin (priority 100): Full system access including user management
* - admin (priority 80): Full event and photo management
* - editor (priority 50): Can edit events and photos but not create or delete
* - viewer (priority 20): Read-only access to dashboard and events
*/
exports.up = async function(knex) {
console.log('Creating roles table...');
// Check if table already exists
const hasRolesTable = await knex.schema.hasTable('roles');
if (!hasRolesTable) {
await knex.schema.createTable('roles', (table) => {
table.increments('id').primary();
table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
table.text('description');
table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Index for name lookups
table.index(['name']);
// Index for priority-based ordering
table.index(['priority']);
});
console.log('Roles table created');
}
// Insert default system roles
const existingRoles = await knex('roles').select('name');
const existingRoleNames = existingRoles.map(r => r.name);
const defaultRoles = [
{
name: 'super_admin',
display_name: 'Super Admin',
description: 'Full system access including user management',
is_system: true,
priority: 100
},
{
name: 'admin',
display_name: 'Admin',
description: 'Full event and photo management',
is_system: true,
priority: 80
},
{
name: 'editor',
display_name: 'Editor',
description: 'Can edit events and photos but not create or delete',
is_system: true,
priority: 50
},
{
name: 'viewer',
display_name: 'Viewer',
description: 'Read-only access to dashboard and events',
is_system: true,
priority: 20
}
];
const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
if (rolesToInsert.length > 0) {
await knex('roles').insert(rolesToInsert);
console.log(`Inserted ${rolesToInsert.length} default roles`);
}
console.log('Roles table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing roles table...');
// Note: This will fail if there are foreign key references
// The role_permissions and admin_users tables must be rolled back first
await knex.schema.dropTableIfExists('roles');
console.log('Roles table removed');
};
@@ -0,0 +1,122 @@
/**
* Migration: Add Permissions Table
* Creates the permissions table for granular access control.
*
* Permission categories:
* - events: View, create, edit, delete, archive events
* - photos: View, upload, edit, delete, download photos
* - archives: View, restore, download, delete archives
* - analytics: View analytics and statistics
* - email: View, edit, send emails
* - branding: View and edit branding settings
* - cms: View and edit CMS pages
* - settings: View and edit application settings
* - backup: View, create, restore, delete backups
* - users: View, create, edit, delete admin users (Super Admin only)
* - activity: View and export activity logs
*/
exports.up = async function(knex) {
console.log('Creating permissions table...');
// Check if table already exists
const hasPermissionsTable = await knex.schema.hasTable('permissions');
if (!hasPermissionsTable) {
await knex.schema.createTable('permissions', (table) => {
table.increments('id').primary();
table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
table.string('display_name', 150).notNullable();
table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
table.text('description');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['name']);
table.index(['category']);
});
console.log('Permissions table created');
}
// Check for existing permissions
const existingPermissions = await knex('permissions').select('name');
const existingPermissionNames = existingPermissions.map(p => p.name);
// Define all permissions
const permissions = [
// Events
{ name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
{ name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
{ name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
{ name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
{ name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
// Photos
{ name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
{ name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
{ name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
{ name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
{ name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
// Archives
{ name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
{ name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
{ name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
{ name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
// Analytics
{ name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
// Email
{ name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
{ name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
{ name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
// Branding & CMS
{ name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
{ name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
{ name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
{ name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
// Settings
{ name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
{ name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
// Backup
{ name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
{ name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
{ name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
{ name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
// User Management (Super Admin only)
{ name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
{ name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
{ name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
{ name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
// Activity Logs
{ name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
{ name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
];
// Filter out already existing permissions
const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
if (permissionsToInsert.length > 0) {
await knex('permissions').insert(permissionsToInsert);
console.log(`Inserted ${permissionsToInsert.length} permissions`);
}
console.log('Permissions table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing permissions table...');
// Note: This will fail if there are foreign key references
// The role_permissions table must be rolled back first
await knex.schema.dropTableIfExists('permissions');
console.log('Permissions table removed');
};
@@ -0,0 +1,134 @@
/**
* Migration: Add Role Permissions Junction Table
* Creates the junction table mapping permissions to roles.
*
* Role permission mappings:
* - super_admin: All permissions
* - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
* - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
* - viewer: View-only access to events, photos, archives, analytics, branding, cms
*/
exports.up = async function(knex) {
console.log('Creating role_permissions junction table...');
// Check if table already exists
const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
if (!hasRolePermissionsTable) {
await knex.schema.createTable('role_permissions', (table) => {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
// Indexes for efficient lookups
table.index(['role_id']);
table.index(['permission_id']);
});
console.log('Role permissions junction table created');
}
// Get role and permission IDs
const roles = await knex('roles').select('id', 'name');
const permissions = await knex('permissions').select('id', 'name');
if (roles.length === 0 || permissions.length === 0) {
console.log('No roles or permissions found, skipping permission mappings');
return;
}
const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
// Define role-permission mappings
const rolePermissions = {
super_admin: permissions.map(p => p.name), // All permissions
admin: [
// Events - full access
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
// Photos - full access
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
// Archives - full access
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
// Analytics - view only
'analytics.view',
// Email - full access
'email.view', 'email.edit', 'email.send',
// Branding - full access
'branding.view', 'branding.edit',
// CMS - full access
'cms.view', 'cms.edit',
// Settings - view only
'settings.view',
// Backup - view and create only
'backup.view', 'backup.create',
// Activity - view only
'activity.view'
],
editor: [
// Events - view, create, and edit (can only see their own events)
'events.view', 'events.create', 'events.edit',
// Photos - view, upload, edit (no delete)
'photos.view', 'photos.upload', 'photos.edit',
// Analytics - view only
'analytics.view',
// Activity - view only
'activity.view'
],
viewer: [
// Events - view only
'events.view',
// Photos - view only
'photos.view',
// Archives - view only
'archives.view',
// Analytics - view only
'analytics.view',
// Branding - view only
'branding.view',
// CMS - view only
'cms.view'
]
};
// Check for existing mappings to avoid duplicates
const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
// Build insert list
const inserts = [];
for (const [roleName, perms] of Object.entries(rolePermissions)) {
for (const permName of perms) {
if (roleMap[roleName] && permMap[permName]) {
const key = `${roleMap[roleName]}-${permMap[permName]}`;
if (!existingSet.has(key)) {
inserts.push({
role_id: roleMap[roleName],
permission_id: permMap[permName]
});
}
}
}
}
if (inserts.length > 0) {
// Insert in batches to avoid hitting database limits
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
const batch = inserts.slice(i, i + batchSize);
await knex('role_permissions').insert(batch);
}
console.log(`Inserted ${inserts.length} role-permission mappings`);
}
console.log('Role permissions junction table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role_permissions junction table...');
await knex.schema.dropTableIfExists('role_permissions');
console.log('Role permissions junction table removed');
};
@@ -0,0 +1,115 @@
/**
* Migration: Add Role to Admin Users
* Adds RBAC-related columns to the admin_users table:
* - role_id: Foreign key to roles table
* - created_by: Foreign key to admin_users (who invited this user)
* - invite_token: Token for invitation acceptance (64 chars = 256 bits)
* - invite_expires_at: When the invitation token expires
* - invite_accepted_at: When the user accepted the invitation
*
* Also migrates existing admin users to super_admin role.
*/
exports.up = async function(knex) {
console.log('Adding role columns to admin_users table...');
// Check if columns already exist
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
// Add new columns if they don't exist
if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
await knex.schema.alterTable('admin_users', (table) => {
if (!hasRoleId) {
// Note: We add as nullable first, then set values, then alter to not null
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
}
if (!hasCreatedBy) {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
}
if (!hasInviteToken) {
// 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
table.string('invite_token', 64);
}
if (!hasInviteExpiresAt) {
table.timestamp('invite_expires_at');
}
if (!hasInviteAcceptedAt) {
table.timestamp('invite_accepted_at');
}
});
console.log('Role columns added to admin_users table');
}
// Add index on invite_token for fast lookup
const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
if (hasInviteTokenIndex) {
// Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
try {
await knex.schema.alterTable('admin_users', (table) => {
table.index(['invite_token']);
});
} catch (e) {
// Index may already exist
if (!e.message.includes('already exists')) {
console.log('Note: invite_token index may already exist');
}
}
}
// Get super_admin role ID
const superAdminRole = await knex('roles').where('name', 'super_admin').first();
if (superAdminRole) {
// Migrate existing admin users without a role to super_admin
const usersWithoutRole = await knex('admin_users')
.whereNull('role_id')
.select('id');
if (usersWithoutRole.length > 0) {
await knex('admin_users')
.whereNull('role_id')
.update({ role_id: superAdminRole.id });
console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
}
} else {
console.log('Warning: super_admin role not found. Run migration 054 first.');
}
console.log('Admin users role migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role columns from admin_users table...');
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
await knex.schema.alterTable('admin_users', (table) => {
if (hasInviteAcceptedAt) {
table.dropColumn('invite_accepted_at');
}
if (hasInviteExpiresAt) {
table.dropColumn('invite_expires_at');
}
if (hasInviteToken) {
table.dropColumn('invite_token');
}
if (hasCreatedBy) {
table.dropColumn('created_by');
}
if (hasRoleId) {
table.dropColumn('role_id');
}
});
console.log('Role columns removed from admin_users table');
};
@@ -0,0 +1,68 @@
/**
* Migration: Add Admin Invitations Table
* Creates the admin_invitations table for managing pending admin user invitations.
*
* Security features:
* - Token is 64 characters (32 bytes hex = 256 bits of entropy)
* - Tokens are unique and indexed for fast lookup
* - Invitations have expiration timestamps
* - Tracks who invited whom and when accepted
* - Foreign key constraints with appropriate CASCADE behavior
*/
exports.up = async function(knex) {
console.log('Creating admin_invitations table...');
// Check if table already exists
const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
if (!hasAdminInvitationsTable) {
await knex.schema.createTable('admin_invitations', (table) => {
table.increments('id').primary();
// Email of the invited user
table.string('email', 255).notNullable();
// Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
// Cryptographically secure for one-time use tokens
table.string('token', 64).unique().notNullable();
// Role to assign when invitation is accepted
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
// Who created this invitation
table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
// When the invitation expires (typically 7 days from creation)
table.timestamp('expires_at').notNullable();
// When the invitation was accepted (null if pending)
table.timestamp('accepted_at');
// The admin_user ID created when invitation was accepted (for audit trail)
table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
// When the invitation was created
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['token']); // Fast token validation
table.index(['email']); // Check for existing invitations by email
table.index(['expires_at']); // Cleanup expired invitations
table.index(['invited_by']); // List invitations by inviter
table.index(['accepted_at']); // Filter pending vs accepted
});
console.log('Admin invitations table created');
}
console.log('Admin invitations table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing admin_invitations table...');
await knex.schema.dropTableIfExists('admin_invitations');
console.log('Admin invitations table removed');
};
@@ -0,0 +1,314 @@
/**
* Migration to add email templates for admin invitation and password reset
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// First, ensure the email_templates table has multilingual columns
// This is needed for fresh installations where legacy migrations don't run
const columnInfo = await knex('email_templates').columnInfo();
if (!columnInfo.subject_en) {
// Need to add multilingual columns
console.log('Adding multilingual columns to email_templates table...');
// Check if we're using SQLite or PostgreSQL
const client = knex.client.config.client;
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
if (isSqlite) {
// SQLite doesn't support column rename directly in all versions
// We need to recreate the table with new structure
// Get existing data
const existingData = await knex('email_templates').select('*');
// Drop the old table
await knex.schema.dropTable('email_templates');
// Create new table with multilingual columns
await knex.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable();
table.string('subject_en');
table.string('subject_de');
table.text('body_html_en');
table.text('body_html_de');
table.text('body_text_en');
table.text('body_text_de');
table.json('variables');
table.datetime('updated_at').defaultTo(knex.fn.now());
});
// Re-insert existing data with column mapping
for (const row of existingData) {
await knex('email_templates').insert({
template_key: row.template_key,
subject_en: row.subject,
subject_de: row.subject, // Copy to German as default
body_html_en: row.body_html,
body_html_de: row.body_html,
body_text_en: row.body_text,
body_text_de: row.body_text,
variables: row.variables,
updated_at: row.updated_at
});
}
console.log('Migrated email_templates table to multilingual structure');
} else {
// PostgreSQL supports ALTER TABLE for column operations
await knex.schema.alterTable('email_templates', (table) => {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
await knex.schema.alterTable('email_templates', (table) => {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy English values to German as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
const existingKeys = existingTemplates.map(t => t.template_key);
// Admin Invitation Email Template
if (!existingKeys.includes('admin_invitation')) {
await knex('email_templates').insert({
template_key: 'admin_invitation',
subject_en: 'You have been invited to join PicPeak as {{role_name}}',
subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
body_html_en: `
<h2>Welcome to PicPeak!</h2>
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
</div>
<p>To accept this invitation and set up your account, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
</div>
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
If the button above does not work, copy and paste this link into your browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Welcome to PicPeak!
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
Your Role: {{role_name}}
This role grants you access to manage and administer the photo sharing platform.
To accept this invitation and set up your account, visit the following link:
{{invite_link}}
IMPORTANT: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Willkommen bei PicPeak!</h2>
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
</div>
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
</div>
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Willkommen bei PicPeak!
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
Ihre Rolle: {{role_name}}
Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
Um diese Einladung anzunehmen und Ihr Konto einzurichten, besuchen Sie den folgenden Link:
{{invite_link}}
WICHTIG: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['invite_link', 'role_name', 'expires_at'])
});
}
// Admin Password Reset Email Template
if (!existingKeys.includes('admin_password_reset')) {
await knex('email_templates').insert({
template_key: 'admin_password_reset',
subject_en: 'Your PicPeak administrator password has been reset',
subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt',
body_html_en: `
<h2>Password Reset Notification</h2>
<p>Hello <strong>{{username}}</strong>,</p>
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>This is a temporary password. Please change it immediately after logging in.</li>
<li>Never share your password with anyone.</li>
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
</ul>
</div>
<p>To log in to the admin panel, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
</div>
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Password Reset Notification
Hello {{username}},
Your administrator password for PicPeak has been reset by a system administrator.
Your New Login Credentials:
- Username: {{username}}
- Temporary Password: {{new_password}}
SECURITY NOTICE:
- This is a temporary password. Please change it immediately after logging in.
- Never share your password with anyone.
- If you did not request this password reset, please contact your system administrator immediately.
To log in to the admin panel, visit: {{admin_login_url}}
After logging in, navigate to your profile settings to change your password to something secure that only you know.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
<p>Hallo <strong>{{username}}</strong>,</p>
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
</ul>
</div>
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Benachrichtigung uber Passwortzurucksetzung
Hallo {{username}},
Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
Ihre neuen Anmeldedaten:
- Benutzername: {{username}}
- Vorlaufiges Passwort: {{new_password}}
SICHERHEITSHINWEIS:
- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.
- Teilen Sie Ihr Passwort niemals mit anderen.
- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.
Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}}
Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['username', 'new_password', 'admin_login_url'])
});
}
};
exports.down = async function(knex) {
// Remove the admin email templates
await knex('email_templates')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset'])
.delete();
};
@@ -0,0 +1,23 @@
/**
* Migration: Add created_by column to events table
* This allows filtering events by owner for role-based access control
*/
exports.up = async function(knex) {
// Add created_by column to events table
await knex.schema.alterTable('events', (table) => {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
});
// Set existing events to be owned by the first admin (super_admin)
const superAdmin = await knex('admin_users').where('role_id', 1).first();
if (superAdmin) {
await knex('events').update({ created_by: superAdmin.id });
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('created_by');
});
};
@@ -3,79 +3,103 @@ const { db } = require('../../src/database/db');
async function up() {
console.log('Adding photo categories and CMS tables...');
// Check if tables already exist
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
const hasLanguageColumn = await db.schema.hasColumn('admin_users', 'language');
// Create photo_categories table
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
// Unique constraint for slug within event scope
table.unique(['slug', 'event_id']);
});
if (!hasPhotoCategoriesTable) {
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
// Unique constraint for slug within event scope
table.unique(['slug', 'event_id']);
});
}
// Create cms_pages table
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
if (!hasCmsPagesTable) {
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
}
// Add category_id to photos table
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
if (!hasCategoryIdColumn) {
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
}
// Add language preference to admin_users
await db.schema.alterTable('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
if (!hasLanguageColumn) {
await db.schema.alterTable('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
}
// Add language preference to app_settings for global default
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date()
});
const hasDefaultLanguageSetting = await db('app_settings')
.where('setting_key', 'default_language')
.first();
if (!hasDefaultLanguageSetting) {
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date()
});
}
// Insert default global categories
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true }
];
if (!hasPhotoCategoriesTable) {
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true }
];
await db('photo_categories').insert(defaultCategories);
await db('photo_categories').insert(defaultCategories);
}
// Insert default legal pages
await db('cms_pages').insert([
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
}
]);
if (!hasCmsPagesTable) {
await db('cms_pages').insert([
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
}
]);
}
console.log('Photo categories and CMS tables created successfully');
}
@@ -1,23 +1,56 @@
exports.up = async function(knex) {
// Add user upload settings to events table
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
// Add user upload settings to events table (check if columns exist first)
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (!hasAllowUserUploads) {
console.log('Adding allow_user_uploads column to events table...');
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
});
} else {
console.log('Column allow_user_uploads already exists in events table, skipping...');
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (!hasUploadCategoryId) {
console.log('Adding upload_category_id column to events table...');
await knex.schema.alterTable('events', function(table) {
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
} else {
console.log('Column upload_category_id already exists in events table, skipping...');
}
// Add uploaded_by field to photos table to track who uploaded
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (!hasUploadedBy) {
console.log('Adding uploaded_by column to photos table...');
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
} else {
console.log('Column uploaded_by already exists in photos table, skipping...');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
table.dropColumn('upload_category_id');
});
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (hasAllowUserUploads) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
});
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (hasUploadCategoryId) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('upload_category_id');
});
}
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (hasUploadedBy) {
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
}
};
@@ -1,17 +1,21 @@
exports.up = function(knex) {
return knex.schema.createTable('login_attempts', table => {
table.increments('id').primary();
table.string('identifier').notNullable(); // username or email
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
table.text('user_agent');
table.timestamp('attempt_time').defaultTo(knex.fn.now());
table.boolean('success').defaultTo(false);
// Indexes for performance
table.index('identifier');
table.index('attempt_time');
table.index(['identifier', 'success', 'attempt_time']);
});
exports.up = async function(knex) {
const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts');
if (!hasLoginAttemptsTable) {
return knex.schema.createTable('login_attempts', table => {
table.increments('id').primary();
table.string('identifier').notNullable(); // username or email
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
table.text('user_agent');
table.timestamp('attempt_time').defaultTo(knex.fn.now());
table.boolean('success').defaultTo(false);
// Indexes for performance
table.index('identifier');
table.index('attempt_time');
table.index(['identifier', 'success', 'attempt_time']);
});
}
};
exports.down = function(knex) {
@@ -1,17 +1,28 @@
exports.up = function(knex) {
exports.up = async function(knex) {
// Check if columns already exist to avoid conflicts
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
const hasLastLoginIp = await knex.schema.hasColumn('admin_users', 'last_login_ip');
const hasTwoFactorEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
const hasTwoFactorSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
return knex.schema.table('admin_users', table => {
// Add password change tracking
table.timestamp('password_changed_at').nullable();
if (!hasPasswordChangedAt) {
table.timestamp('password_changed_at').nullable();
}
// Add last login IP for security monitoring
table.string('last_login_ip', 45).nullable();
// Add last login IP for security monitoring
if (!hasLastLoginIp) {
table.string('last_login_ip', 45).nullable();
}
// Add account security flags
table.boolean('two_factor_enabled').defaultTo(false);
table.string('two_factor_secret').nullable();
// Add index for performance
table.index('password_changed_at');
if (!hasTwoFactorEnabled) {
table.boolean('two_factor_enabled').defaultTo(false);
}
if (!hasTwoFactorSecret) {
table.string('two_factor_secret').nullable();
}
});
};
@@ -1,7 +1,11 @@
exports.up = function(knex) {
return knex.schema
// Table for individual token revocations
.createTable('revoked_tokens', table => {
exports.up = async function(knex) {
// Check if tables already exist to avoid conflicts
const hasRevokedTokensTable = await knex.schema.hasTable('revoked_tokens');
const hasUserTokenRevocationsTable = await knex.schema.hasTable('user_token_revocations');
// Create revoked_tokens table if it doesn't exist
if (!hasRevokedTokensTable) {
await knex.schema.createTable('revoked_tokens', table => {
table.increments('id').primary();
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
table.integer('user_id').nullable(); // User who owned the token
@@ -15,9 +19,12 @@ exports.up = function(knex) {
table.index('token_id');
table.index('user_id');
table.index('expires_at'); // For cleanup
})
// Table for user-level revocations (revoke all tokens before a certain time)
.createTable('user_token_revocations', table => {
});
}
// Create user_token_revocations table if it doesn't exist
if (!hasUserTokenRevocationsTable) {
await knex.schema.createTable('user_token_revocations', table => {
table.integer('user_id').primary();
table.timestamp('revoked_at').notNullable();
table.string('reason', 100);
@@ -25,6 +32,28 @@ exports.up = function(knex) {
// Index for quick lookups
table.index('revoked_at');
});
}
// Add any missing indexes if tables already existed
if (hasRevokedTokensTable) {
try {
// Try to add indexes if they don't exist (PostgreSQL syntax)
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_token_id_index" ON "revoked_tokens" ("token_id")');
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_user_id_index" ON "revoked_tokens" ("user_id")');
await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_expires_at_index" ON "revoked_tokens" ("expires_at")');
} catch (error) {
// For SQLite compatibility, ignore errors if indexes already exist
console.log('Note: Some indexes may already exist, continuing...');
}
}
if (hasUserTokenRevocationsTable) {
try {
await knex.raw('CREATE INDEX IF NOT EXISTS "user_token_revocations_revoked_at_index" ON "user_token_revocations" ("revoked_at")');
} catch (error) {
console.log('Note: Some indexes may already exist, continuing...');
}
}
};
exports.down = function(knex) {
+4 -2
View File
@@ -120,7 +120,9 @@ async function runMigrations() {
// Check if this is a new deployment
// It's new if no essential tables exist OR no migrations have been applied
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
// Only detect existing schema for truly existing deployments
if (!isNewDeployment) {
@@ -227,4 +229,4 @@ if (require.main === module) {
waitAndRun();
}
module.exports = { runMigrations };
module.exports = { runMigrations };
+2363 -1382
View File
File diff suppressed because it is too large Load Diff
+19 -8
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.95",
"version": "2.2.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,42 +8,44 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"fix-temp-photos": "node scripts/fix-temp-photos.js",
"test": "jest",
"lint": "eslint src/",
"test-backup": "node scripts/test-backup-service.js",
"test-restore": "node scripts/test-restore-service.js"
"lint": "eslint src/"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
"axios": "^1.12.2",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "7.0.5",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"uuid": "^11.1.0",
@@ -53,7 +55,16 @@
"devDependencies": {
"eslint": "^8.40.0",
"jest": "^29.5.0",
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10",
"supertest": "^6.3.3"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
},
"glob": "^11.1.0",
"body-parser": "^2.2.1",
"js-yaml": "^4.1.1"
}
}
Executable → Regular
View File
View File
Executable → Regular
View File
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env node
/**
* Fix migration state by marking migrations as applied if their tables already exist
*/
const { db } = require('../src/database/db');
async function fixMigrationState() {
try {
console.log('Checking migration state...');
// Ensure migrations table exists
const hasMigrationsTable = await db.schema.hasTable('migrations');
if (!hasMigrationsTable) {
await db.schema.createTable('migrations', (table) => {
table.increments('id').primary();
table.string('filename').unique().notNullable();
table.timestamp('applied_at').defaultTo(db.fn.now());
});
console.log('Created migrations tracking table');
}
// Check for specific tables and mark their migrations as applied
const tableChecks = [
{ table: 'restore_runs', migration: '032_add_restore_runs_table.js' },
{ table: 'restore_file_operations', migration: '032_add_restore_runs_table.js' },
{ table: 'restore_validation_results', migration: '032_add_restore_runs_table.js' },
{ table: 'gallery_feedback', migration: '033_add_gallery_feedback.js' },
{ table: 'feedback_photos', migration: '033_add_gallery_feedback.js' },
];
for (const check of tableChecks) {
const tableExists = await db.schema.hasTable(check.table);
if (tableExists) {
const migrationApplied = await db('migrations')
.where('filename', check.migration)
.first();
if (!migrationApplied) {
await db('migrations').insert({
filename: check.migration,
applied_at: new Date()
});
console.log(`✅ Marked ${check.migration} as applied (table ${check.table} exists)`);
} else {
console.log(`${check.migration} already marked as applied`);
}
}
}
console.log('\nMigration state fixed successfully!');
} catch (error) {
console.error('Error fixing migration state:', error.message);
process.exit(1);
} finally {
await db.destroy();
}
}
fixMigrationState();
-171
View File
@@ -1,171 +0,0 @@
require('dotenv').config({ path: '../.env' });
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const { generatePhotoFilename } = require('../src/utils/filenameSanitizer');
async function fixTempPhotos() {
console.log('Starting to fix temporary photo files...\n');
try {
// Find all photos with temp_ filenames
const tempPhotos = await db('photos')
.where('filename', 'like', 'temp_%')
.orderBy('event_id', 'asc')
.orderBy('category_id', 'asc')
.orderBy('id', 'asc');
console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`);
if (tempPhotos.length === 0) {
console.log('No temporary photos found. Exiting.');
return;
}
// Group photos by event and category
const grouped = {};
for (const photo of tempPhotos) {
const key = `${photo.event_id}_${photo.category_id || 'null'}`;
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(photo);
}
console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`);
// Process each group
for (const [key, photos] of Object.entries(grouped)) {
const [eventId, categoryIdStr] = key.split('_');
const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr);
console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`);
console.log(`Photos in group: ${photos.length}`);
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error(`Event ${eventId} not found! Skipping...`);
continue;
}
// Get category details if applicable
let category = null;
let startCounter = 1;
if (categoryId) {
category = await db('photo_categories').where({ id: categoryId }).first();
if (!category) {
console.error(`Category ${categoryId} not found! Treating as uncategorized...`);
} else {
// Get the highest counter for this category
const maxPhoto = await db('photos')
.where({ event_id: eventId, category_id: categoryId })
.whereNot('filename', 'like', 'temp_%')
.orderBy('id', 'desc')
.first();
if (maxPhoto && maxPhoto.filename) {
// Extract counter from filename
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
if (match) {
startCounter = parseInt(match[1]) + 1;
}
}
}
} else {
// For uncategorized, get the highest counter
const maxPhoto = await db('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.whereNot('filename', 'like', 'temp_%')
.orderBy('id', 'desc')
.first();
if (maxPhoto && maxPhoto.filename) {
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
if (match) {
startCounter = parseInt(match[1]) + 1;
}
}
}
console.log(`Starting counter: ${startCounter}`);
// Process each photo in the group
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const counter = startCounter + i;
try {
// Generate new filename
const extension = path.extname(photo.filename);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Build full paths
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const oldPath = path.join(storagePath, 'events/active', photo.path);
const newPath = path.join(path.dirname(oldPath), newFilename);
// Check if old file exists
try {
await fs.access(oldPath);
} catch (e) {
console.error(`File not found: ${oldPath}`);
errorCount++;
continue;
}
// Rename the file
await fs.rename(oldPath, newPath);
// Update database
const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
await db('photos')
.where({ id: photo.id })
.update({
filename: newFilename,
path: newRelativePath
});
console.log(`✓ Renamed: ${photo.filename}${newFilename}`);
successCount++;
} catch (error) {
console.error(`✗ Failed to process photo ${photo.id}: ${error.message}`);
errorCount++;
}
}
// Update category counter if needed
if (category && successCount > 0) {
const newCounter = startCounter + photos.length - 1;
await db('photo_categories')
.where({ id: categoryId })
.update({ photo_counter: newCounter });
console.log(`Updated category counter to ${newCounter}`);
}
console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`);
}
console.log('\n=== COMPLETE ===');
console.log('All temporary photos have been processed.');
} catch (error) {
console.error('Fatal error:', error);
} finally {
await db.destroy();
}
}
// Run the script
fixTempPhotos().catch(console.error);
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
/**
* Mark a specific migration as applied without running it
* Usage: node scripts/mark-migration-applied.js <migration-filename>
*/
const { db } = require('../src/database/db');
async function markMigrationAsApplied(filename) {
try {
// Check if migration is already marked
const existing = await db('migrations')
.where('filename', filename)
.first();
if (existing) {
console.log(`Migration ${filename} is already marked as applied`);
return;
}
// Mark as applied
await db('migrations').insert({
filename,
applied_at: new Date()
});
console.log(`✅ Migration ${filename} marked as applied`);
} catch (error) {
console.error('Error marking migration:', error.message);
process.exit(1);
} finally {
await db.destroy();
}
}
// Get migration filename from command line
const migrationFile = process.argv[2];
if (!migrationFile) {
console.error('Usage: node scripts/mark-migration-applied.js <migration-filename>');
console.error('Example: node scripts/mark-migration-applied.js 032_add_restore_runs_table.js');
process.exit(1);
}
markMigrationAsApplied(migrationFile);
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fsp = fs.promises;
async function pathExists(location) {
try {
await fsp.access(location);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function moveFile(source, destination) {
await fsp.mkdir(path.dirname(destination), { recursive: true });
try {
await fsp.rename(source, destination);
} catch (error) {
if (error.code === 'EXDEV') {
await fsp.copyFile(source, destination);
await fsp.unlink(source);
} else if (error.code === 'EEXIST') {
console.warn(`Destination already exists, leaving original in place: ${destination}`);
return;
} else {
throw error;
}
}
}
async function migrate() {
const backendRoot = path.resolve(__dirname, '..');
const defaultStorage = path.resolve(backendRoot, '../storage');
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
const targetUploadsRoot = path.join(targetStorage, 'uploads');
if (legacyUploadsRoot === targetUploadsRoot) {
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
return;
}
if (!fs.existsSync(legacyUploadsRoot)) {
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
return;
}
const categories = ['logos', 'favicons'];
let migratedCounter = 0;
for (const category of categories) {
const legacyDir = path.join(legacyUploadsRoot, category);
if (!fs.existsSync(legacyDir)) {
continue;
}
const targetDir = path.join(targetUploadsRoot, category);
await fsp.mkdir(targetDir, { recursive: true });
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const sourcePath = path.join(legacyDir, entry.name);
const destinationPath = path.join(targetDir, entry.name);
if (await pathExists(destinationPath)) {
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
continue;
}
await moveFile(sourcePath, destinationPath);
migratedCounter += 1;
}
const remaining = await fsp.readdir(legacyDir);
if (remaining.length === 0) {
await fsp.rm(legacyDir, { recursive: true, force: true });
}
}
if (migratedCounter === 0) {
console.log('No legacy logo or favicon files needed migration.');
return;
}
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
}
migrate().catch((error) => {
console.error('Migration failed:', error);
process.exitCode = 1;
});
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Script to regenerate all thumbnails with new square dimensions
* This fixes the blurry thumbnail issue by creating proper 300x300 square thumbnails
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const { generateThumbnail } = require('../src/services/imageProcessor');
const logger = require('../src/utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
async function regenerateAllThumbnails() {
try {
console.log('Starting thumbnail regeneration with square dimensions...');
// First, ensure the new thumbnail settings are in the database
const settings = [
{ key: 'thumbnail_width', value: '300' },
{ key: 'thumbnail_height', value: '300' },
{ key: 'thumbnail_fit', value: 'cover' },
{ key: 'thumbnail_quality', value: '85' },
{ key: 'thumbnail_format', value: 'jpeg' }
];
for (const setting of settings) {
const exists = await db('app_settings').where('key', setting.key).first();
if (!exists) {
await db('app_settings').insert({
...setting,
description: `Thumbnail ${setting.key.replace('thumbnail_', '')}`,
created_at: db.fn.now(),
updated_at: db.fn.now()
});
console.log(`Added setting: ${setting.key} = ${setting.value}`);
}
}
// Get all photos
const photos = await db('photos')
.select('id', 'event_id', 'path', 'filename')
.orderBy('id');
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const progress = Math.round((i + 1) / photos.length * 100);
try {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
// Check if original file exists
try {
await fs.access(originalPath);
} catch (err) {
console.log(`[${progress}%] Skipping photo ${photo.id} - original file not found`);
skippedCount++;
continue;
}
// Regenerate thumbnail with new square dimensions
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
if (thumbnailPath) {
// Update database with new thumbnail path
await db('photos')
.where({ id: photo.id })
.update({
thumbnail_path: thumbnailPath,
updated_at: db.fn.now()
});
successCount++;
console.log(`[${progress}%] ✓ Regenerated thumbnail for ${photo.filename}`);
} else {
errorCount++;
console.error(`[${progress}%] ✗ Failed to generate thumbnail for ${photo.filename}`);
}
} catch (error) {
errorCount++;
console.error(`[${progress}%] ✗ Error processing photo ${photo.id}:`, error.message);
}
}
console.log('\n=== Regeneration Complete ===');
console.log(`✓ Success: ${successCount} thumbnails`);
console.log(`✗ Errors: ${errorCount} thumbnails`);
console.log(`⊘ Skipped: ${skippedCount} thumbnails (original files not found)`);
console.log(`Total processed: ${photos.length} photos`);
process.exit(0);
} catch (error) {
console.error('Fatal error during thumbnail regeneration:', error);
process.exit(1);
}
}
// Run the script
regenerateAllThumbnails();
View File
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
const rl = readline.createInterface({
const args = process.argv.slice(2);
const hasFlag = (flag) => args.includes(flag);
const getOption = (name) => {
const index = args.indexOf(`--${name}`);
if (index !== -1 && index + 1 < args.length) {
return args[index + 1];
}
return null;
};
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
const credentialsFileArg = getOption('credentials-file');
const resolvedCredentialsFile = credentialsFileArg
? path.resolve(process.cwd(), credentialsFileArg)
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const rl = force ? null : readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function question(prompt) {
async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => {
rl.question(prompt, resolve);
});
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username);
console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.');
const confirm = await question('\nDo you want to continue? (yes/no): ');
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
if (!force) {
console.log('\nThis will reset the password for this admin account.');
}
const confirm = await ask('\nDo you want to continue? (yes/no): ');
if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
}
// Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
});
// Save to file
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const credentialsDir = path.dirname(resolvedCredentialsFile);
await fs.mkdir(credentialsDir, { recursive: true });
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
const resetInfo = `
========================================
PicPeak Admin Password Reset
PicPeak Admin Credentials
========================================
Password has been reset for admin account:
Your admin account has been reset with these credentials:
Username: admin
New Password: ${newPassword}
Username: ${admin.username}
Email: ${admin.email}
Password: ${newPassword}
IMPORTANT:
1. You MUST change this password on next login
IMPORTANT SECURITY NOTES:
1. You MUST change this password after first login
2. This file contains sensitive information
3. Delete this file after noting the password
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
Login URL: ${adminUrl}
Reset performed on: ${new Date().toISOString()}
========================================
`;
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
console.log('\n✅ Password reset successful!\n');
console.log('========================================');
console.log('New Credentials:');
console.log('========================================');
console.log('Username: admin');
console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login');
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
console.log('3. Delete the file after noting the password');
console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message);
process.exit(1);
} finally {
rl.close();
if (rl) {
rl.close();
}
process.exit(0);
}
}
// Run the reset
resetAdminPassword();
resetAdminPassword();
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Script to set/reset admin password
*
* Usage:
* node set-admin-password.js <new-password>
* node set-admin-password.js --env (uses ADMIN_PASSWORD environment variable)
*
* Security: Password must be at least 8 characters with mixed case, numbers, and special characters
*/
const bcrypt = require('bcrypt');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const knex = require('knex');
const db = knex({
client: process.env.DB_CLIENT || 'pg',
connection: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD || 'picpeak',
database: process.env.DB_NAME || 'picpeak_dev'
}
});
/**
* Validate password strength
*/
function validatePassword(password) {
if (!password || password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters long' };
}
if (!/[a-z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain at least one number' };
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
return { valid: false, error: 'Password must contain at least one special character' };
}
return { valid: true };
}
function printUsage() {
console.log(`
Usage:
node set-admin-password.js <new-password>
node set-admin-password.js --env
Options:
<new-password> The new password to set (must meet security requirements)
--env Use ADMIN_PASSWORD environment variable
Security Requirements:
- At least 8 characters
- At least one lowercase letter
- At least one uppercase letter
- At least one number
- At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)
Examples:
node set-admin-password.js "MySecure@Pass123"
ADMIN_PASSWORD="MySecure@Pass123" node set-admin-password.js --env
`);
}
async function setAdminPassword() {
try {
// Get password from argument or environment
const args = process.argv.slice(2);
let password;
if (args.length === 0) {
console.error('❌ Error: No password provided\n');
printUsage();
process.exit(1);
}
if (args[0] === '--env') {
password = process.env.ADMIN_PASSWORD;
if (!password) {
console.error('❌ Error: ADMIN_PASSWORD environment variable not set');
process.exit(1);
}
} else if (args[0] === '--help' || args[0] === '-h') {
printUsage();
process.exit(0);
} else {
password = args[0];
}
// Validate password strength
const validation = validatePassword(password);
if (!validation.valid) {
console.error(`❌ Error: ${validation.error}`);
process.exit(1);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Update database
const updated = await db('admin_users')
.where('username', 'admin')
.update({
password_hash: hashedPassword,
password_changed_at: new Date(),
updated_at: new Date()
});
if (updated === 0) {
console.error('❌ Error: Admin user not found');
process.exit(1);
}
console.log('✅ Admin password updated successfully');
console.log(' Note: All existing sessions have been invalidated');
await db.destroy();
process.exit(0);
} catch (error) {
console.error('❌ Error setting password:', error.message);
await db.destroy();
process.exit(1);
}
}
setAdminPassword();
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Show or reset admin credentials
*/
const bcrypt = require('bcrypt');
const { db } = require('../src/database/db');
const { generateReadablePassword } = require('../src/utils/passwordGenerator');
async function showAdminCredentials(resetPassword = false) {
try {
// Get admin user
const admin = await db('admin_users')
.where('username', 'admin')
.first();
if (!admin) {
console.error('❌ No admin user found in database');
process.exit(1);
}
console.log('\n========================================');
console.log('PicPeak Admin Credentials');
console.log('========================================');
console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
if (resetPassword) {
// Generate new password
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, 12);
// Update password
await db('admin_users')
.where('id', admin.id)
.update({
password_hash: passwordHash,
must_change_password: true,
updated_at: new Date()
});
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]');
}
console.log('\nLogin URL: http://localhost:3001/admin');
console.log('========================================\n');
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
} finally {
await db.destroy();
}
}
// Check for reset flag
const resetPassword = process.argv.includes('--reset');
if (resetPassword) {
console.log('🔄 Resetting admin password...');
}
showAdminCredentials(resetPassword);
-577
View File
@@ -1,577 +0,0 @@
#!/usr/bin/env node
/**
* Manual Integration Test Script for Enhanced Backup System
*
* This script provides a comprehensive test of the backup system with real services.
* It can be used to test against MinIO, AWS S3, or other S3-compatible services.
*
* Usage:
* node scripts/test-backup-integration.js [options]
*
* Options:
* --endpoint <url> S3 endpoint URL (default: http://localhost:9000)
* --access-key <key> S3 access key (default: minioadmin)
* --secret-key <key> S3 secret key (default: minioadmin)
* --bucket <name> S3 bucket name (default: test-backup-<timestamp>)
* --type <type> Backup type: s3, local, rsync (default: s3)
* --cleanup Clean up test data after completion
* --verbose Enable verbose logging
* --help Show this help message
*
* Examples:
* # Test with local MinIO
* node scripts/test-backup-integration.js
*
* # Test with AWS S3
* node scripts/test-backup-integration.js \
* --endpoint https://s3.amazonaws.com \
* --access-key AKIAIOSFODNN7EXAMPLE \
* --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
* --bucket my-test-bucket
*
* # Test local backup
* node scripts/test-backup-integration.js --type local
*/
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3');
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
endpoint: 'http://localhost:9000',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
bucket: `test-backup-${Date.now()}`,
type: 's3',
cleanup: false,
verbose: false
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--endpoint':
options.endpoint = args[++i];
break;
case '--access-key':
options.accessKey = args[++i];
break;
case '--secret-key':
options.secretKey = args[++i];
break;
case '--bucket':
options.bucket = args[++i];
break;
case '--type':
options.type = args[++i];
break;
case '--cleanup':
options.cleanup = true;
break;
case '--verbose':
options.verbose = true;
break;
case '--help':
console.log(module.exports.description || 'Manual Integration Test Script');
process.exit(0);
}
}
// Load environment and services
require('dotenv').config();
const { db, initialize: initDb } = require('../src/database/db');
const backupService = require('../src/services/backupService');
const S3StorageAdapter = require('../src/services/storage/s3Storage');
const logger = require('../src/utils/logger');
// Configure logger based on verbose flag
if (!options.verbose) {
logger.info = () => {};
logger.debug = () => {};
}
// Test results
const results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
// Test utilities
async function runTest(name, testFn) {
console.log(`\n📋 Running: ${name}`);
try {
const startTime = Date.now();
await testFn();
const duration = Date.now() - startTime;
console.log(`✅ PASSED: ${name} (${duration}ms)`);
results.passed++;
results.tests.push({ name, status: 'passed', duration });
} catch (error) {
console.error(`❌ FAILED: ${name}`);
console.error(` Error: ${error.message}`);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
results.tests.push({ name, status: 'failed', error: error.message });
}
}
async function skipTest(name, reason) {
console.log(`\n⏭️ Skipping: ${name}`);
console.log(` Reason: ${reason}`);
results.skipped++;
results.tests.push({ name, status: 'skipped', reason });
}
// Test functions
async function testS3Connection() {
const s3Adapter = new S3StorageAdapter({
bucket: options.bucket,
endpoint: options.endpoint,
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey,
region: 'us-east-1',
forcePathStyle: true,
sslEnabled: options.endpoint.startsWith('https')
});
await s3Adapter.testConnection();
console.log(` ✓ Connected to S3 endpoint: ${options.endpoint}`);
console.log(` ✓ Bucket accessible: ${options.bucket}`);
}
async function setupTestData() {
const storagePath = path.join(__dirname, '../test-storage');
process.env.STORAGE_PATH = storagePath;
// Create directory structure
const dirs = [
'events/active/wedding-2024',
'events/active/birthday-2024',
'events/archived',
'thumbnails',
'uploads',
'backups'
];
for (const dir of dirs) {
await fs.mkdir(path.join(storagePath, dir), { recursive: true });
}
// Create test files with various sizes
const files = [
{ path: 'events/active/wedding-2024/photo1.jpg', size: 1024 * 1024 }, // 1MB
{ path: 'events/active/wedding-2024/photo2.jpg', size: 512 * 1024 }, // 512KB
{ path: 'events/active/birthday-2024/photo1.jpg', size: 2 * 1024 * 1024 }, // 2MB
{ path: 'events/archived/old-event.zip', size: 5 * 1024 * 1024 }, // 5MB
{ path: 'thumbnails/thumb1.jpg', size: 50 * 1024 }, // 50KB
{ path: 'uploads/logo.png', size: 100 * 1024 } // 100KB
];
let totalSize = 0;
for (const file of files) {
const content = crypto.randomBytes(file.size);
await fs.writeFile(path.join(storagePath, file.path), content);
totalSize += file.size;
}
console.log(` ✓ Created ${files.length} test files`);
console.log(` ✓ Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
return { storagePath, fileCount: files.length, totalSize };
}
async function configureBackup(type) {
const baseSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: `"${type}"` },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_include_database', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_manifest_format', setting_value: '"json"' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
const typeSpecificSettings = {
s3: [
{ setting_key: 'backup_s3_bucket', setting_value: `"${options.bucket}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${options.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${options.accessKey}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${options.secretKey}"` },
{ setting_key: 'backup_s3_region', setting_value: '"us-east-1"' },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: options.endpoint.startsWith('https') ? 'true' : 'false' }
],
local: [
{ setting_key: 'backup_destination_path', setting_value: `"${path.join(__dirname, '../test-backup')}"` }
],
rsync: [
{ setting_key: 'backup_rsync_host', setting_value: '"localhost"' },
{ setting_key: 'backup_rsync_path', setting_value: `"${path.join(__dirname, '../test-backup-rsync')}"` }
]
};
const settings = [...baseSettings, ...(typeSpecificSettings[type] || [])];
// Clear existing settings
await db('app_settings').where('setting_type', 'backup').del();
// Insert new settings
for (const setting of settings) {
await db('app_settings').insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
});
}
console.log(` ✓ Configured ${type} backup with ${settings.length} settings`);
}
async function performBackup() {
const startTime = Date.now();
// Run the backup
await backupService.runBackup();
// Get backup results
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
if (!backupRun) {
throw new Error('No backup run found');
}
if (backupRun.status !== 'completed') {
throw new Error(`Backup failed with status: ${backupRun.status}, error: ${backupRun.error_message}`);
}
const duration = Date.now() - startTime;
console.log(` ✓ Backup completed in ${duration}ms`);
console.log(` ✓ Files backed up: ${backupRun.files_backed_up}`);
console.log(` ✓ Total size: ${(backupRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` ✓ Manifest: ${backupRun.manifest_path ? 'Generated' : 'Not generated'}`);
return backupRun;
}
async function verifyS3Backup(backupRun) {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
// List objects in bucket
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
const objects = listResponse.Contents || [];
console.log(` ✓ Objects in S3: ${objects.length}`);
// Verify key components
const hasBackupFolder = objects.some(obj => obj.Key.includes('backup-'));
const hasManifest = objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = objects.some(obj => obj.Key.includes('backup-summary.json'));
const hasPhotos = objects.some(obj => obj.Key.includes('events/active'));
if (!hasBackupFolder) throw new Error('No backup folder found in S3');
if (!hasManifest) throw new Error('No manifest found in S3');
if (!hasSummary) throw new Error('No summary found in S3');
if (!hasPhotos) throw new Error('No photos found in S3');
console.log(` ✓ Backup structure verified`);
// Download and verify a file
const photoObject = objects.find(obj => obj.Key.includes('photo1.jpg'));
if (photoObject) {
const getResponse = await s3Client.send(new GetObjectCommand({
Bucket: options.bucket,
Key: photoObject.Key
}));
const chunks = [];
for await (const chunk of getResponse.Body) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);
console.log(` ✓ Downloaded test file: ${photoObject.Key} (${content.length} bytes)`);
}
}
async function testIncrementalBackup(testData) {
// Modify a file
const modifiedFile = path.join(testData.storagePath, 'events/active/wedding-2024/photo1.jpg');
const newContent = crypto.randomBytes(1024 * 1024 + 100); // Slightly larger
await fs.writeFile(modifiedFile, newContent);
console.log(` ✓ Modified test file`);
// Perform incremental backup
const backupRun = await performBackup();
if (backupRun.files_backed_up !== 1) {
throw new Error(`Expected 1 file in incremental backup, got ${backupRun.files_backed_up}`);
}
console.log(` ✓ Incremental backup correctly identified changed file`);
// Verify manifest indicates incremental
if (backupRun.manifest_path) {
const { manifest } = await backupService.getBackupManifest(backupRun.id);
if (!manifest.incremental) {
throw new Error('Manifest does not indicate incremental backup');
}
console.log(` ✓ Manifest correctly marked as incremental`);
}
return backupRun;
}
async function testManifestValidation(backupRun) {
if (!backupRun.manifest_path) {
throw new Error('No manifest path in backup run');
}
const result = await backupService.validateBackupManifest(backupRun.manifest_path);
if (!result.valid) {
throw new Error(`Manifest validation failed: ${result.error}`);
}
console.log(` ✓ Manifest validation passed`);
console.log(` ✓ Manifest version: ${result.manifest.manifest.version}`);
console.log(` ✓ Files in manifest: ${result.manifest.files.count}`);
}
async function testBackupStatus() {
const status = await backupService.getBackupStatus(5);
console.log(` ✓ Backup service running: ${status.isRunning}`);
console.log(` ✓ Backup service healthy: ${status.isHealthy}`);
console.log(` ✓ Recent runs: ${status.recentRuns.length}`);
if (status.lastRun) {
console.log(` ✓ Last run status: ${status.lastRun.status}`);
console.log(` ✓ Manifest valid: ${status.lastRun.manifestValid}`);
}
}
async function cleanupTestData() {
if (!options.cleanup) {
console.log('\n📌 Test data retained for inspection');
console.log(` Storage: ${process.env.STORAGE_PATH}`);
if (options.type === 's3') {
console.log(` S3 Bucket: ${options.bucket}`);
}
return;
}
console.log('\n🧹 Cleaning up test data...');
// Clean storage directory
if (process.env.STORAGE_PATH) {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true });
console.log(' ✓ Removed test storage directory');
}
// Clean S3 bucket if used
if (options.type === 's3') {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
// List and delete all objects
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: options.bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key }))
}
}));
console.log(` ✓ Deleted ${listResponse.Contents.length} objects from S3`);
}
// Delete bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: options.bucket
}));
console.log(` ✓ Deleted S3 bucket: ${options.bucket}`);
} catch (error) {
console.error(` ⚠️ Failed to cleanup S3: ${error.message}`);
}
}
// Clean backup directories
const backupDirs = [
path.join(__dirname, '../test-backup'),
path.join(__dirname, '../test-backup-rsync')
];
for (const dir of backupDirs) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
console.log(' ✓ Removed backup directories');
}
// Main test runner
async function main() {
console.log('🚀 Enhanced Backup System Integration Test');
console.log('==========================================');
console.log(`Type: ${options.type}`);
console.log(`Endpoint: ${options.endpoint}`);
console.log(`Bucket: ${options.bucket}`);
console.log('');
let s3Client;
let testData;
try {
// Initialize database
console.log('📦 Initializing database...');
await initDb();
await db.migrate.latest();
console.log(' ✓ Database initialized');
// S3-specific setup
if (options.type === 's3') {
// Test S3 connection
await runTest('S3 Connection Test', testS3Connection);
// Create S3 bucket if needed
s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
await s3Client.send(new HeadBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Using existing bucket: ${options.bucket}`);
} catch (error) {
if (error.name === 'NotFound') {
await s3Client.send(new CreateBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Created new bucket: ${options.bucket}`);
} else {
throw error;
}
}
}
// Setup test data
console.log('\n📁 Setting up test data...');
testData = await setupTestData();
// Configure backup
console.log(`\n⚙️ Configuring ${options.type} backup...`);
await configureBackup(options.type);
// Run tests based on backup type
await runTest('Initial Full Backup', performBackup);
if (options.type === 's3') {
await runTest('Verify S3 Backup Contents', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await verifyS3Backup(lastRun);
});
}
await runTest('Incremental Backup', () => testIncrementalBackup(testData));
await runTest('Manifest Validation', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await testManifestValidation(lastRun);
});
await runTest('Backup Status Check', testBackupStatus);
// Performance test with larger files
if (options.type === 's3') {
await runTest('Large File Backup (10MB)', async () => {
const largeFile = path.join(testData.storagePath, 'events/active/large.jpg');
await fs.writeFile(largeFile, crypto.randomBytes(10 * 1024 * 1024));
await performBackup();
});
}
// Test backup service lifecycle
await runTest('Backup Service Start/Stop', async () => {
await backupService.startBackupService();
console.log(' ✓ Service started');
backupService.stopBackupService();
console.log(' ✓ Service stopped');
});
// Print results summary
console.log('\n📊 Test Results Summary');
console.log('======================');
console.log(`✅ Passed: ${results.passed}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`⏭️ Skipped: ${results.skipped}`);
console.log(`📋 Total: ${results.tests.length}`);
if (results.failed > 0) {
console.log('\nFailed Tests:');
results.tests
.filter(t => t.status === 'failed')
.forEach(t => console.log(` - ${t.name}: ${t.error}`));
}
} catch (error) {
console.error('\n💥 Fatal error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
} finally {
// Cleanup
await cleanupTestData();
// Close database
await db.destroy();
// Exit with appropriate code
process.exit(results.failed > 0 ? 1 : 0);
}
}
// Run if called directly
if (require.main === module) {
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
}
module.exports = { runTest, skipTest };
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
require('dotenv').config();
const { initializeDatabase } = require('../src/database/db');
const { runBackup, getBackupStatus } = require('../src/services/backupService');
const logger = require('../src/utils/logger');
async function testBackupService() {
try {
console.log('Testing backup service...\n');
// Initialize database
await initializeDatabase();
// Get current backup status
console.log('Getting backup status...');
const statusBefore = await getBackupStatus();
console.log('Last run:', statusBefore.lastRun ? statusBefore.lastRun.started_at : 'Never');
console.log('Is healthy:', statusBefore.isHealthy);
console.log('');
// Run backup
console.log('Running backup...');
await runBackup();
// Get status after backup
console.log('\nGetting status after backup...');
const statusAfter = await getBackupStatus();
console.log('Last run:', statusAfter.lastRun ? statusAfter.lastRun.started_at : 'Never');
console.log('Status:', statusAfter.lastRun ? statusAfter.lastRun.status : 'Unknown');
console.log('Files backed up:', statusAfter.lastRun ? statusAfter.lastRun.files_backed_up : 0);
console.log('Total size:', statusAfter.lastRun ? `${(statusAfter.lastRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB` : '0 MB');
if (statusAfter.lastRun && statusAfter.lastRun.error_message) {
console.log('Error:', statusAfter.lastRun.error_message);
}
console.log('\nBackup test completed!');
process.exit(0);
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}
}
testBackupService();
-325
View File
@@ -1,325 +0,0 @@
/**
* Test script for the restore service
*
* This script demonstrates the restore service functionality with safety checks
*
* Usage:
* node scripts/test-restore-service.js [options]
*
* Options:
* --dry-run Perform validation only without actual restore
* --force Force restore even with warnings
* --type Restore type: full, database, files, selective (default: full)
* --source Backup source path or S3 URL
* --manifest Path to backup manifest
*/
require('dotenv').config();
const { restoreService } = require('../src/services/restoreService');
const { db } = require('../src/database/db');
const logger = require('../src/utils/logger');
const path = require('path');
const fs = require('fs').promises;
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
dryRun: args.includes('--dry-run'),
force: args.includes('--force'),
restoreType: 'full',
source: null,
manifestPath: null
};
// Parse restore type
const typeIndex = args.indexOf('--type');
if (typeIndex !== -1 && args[typeIndex + 1]) {
options.restoreType = args[typeIndex + 1];
}
// Parse source
const sourceIndex = args.indexOf('--source');
if (sourceIndex !== -1 && args[sourceIndex + 1]) {
options.source = args[sourceIndex + 1];
}
// Parse manifest
const manifestIndex = args.indexOf('--manifest');
if (manifestIndex !== -1 && args[manifestIndex + 1]) {
options.manifestPath = args[manifestIndex + 1];
}
async function testRestore() {
console.log('=== PicPeak Restore Service Test ===\n');
try {
// If no source/manifest provided, try to find a recent backup
if (!options.source || !options.manifestPath) {
console.log('No backup source specified. Looking for recent backups...\n');
const recentBackup = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.first();
if (!recentBackup) {
console.error('❌ No completed backups found in the database');
console.log('\nPlease run a backup first or specify --source and --manifest');
process.exit(1);
}
console.log(`Found recent backup from ${recentBackup.completed_at}`);
console.log(`Backup ID: ${recentBackup.manifest_id}`);
console.log(`Files backed up: ${recentBackup.files_backed_up}`);
console.log(`Total size: ${(recentBackup.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Manifest: ${recentBackup.manifest_path}\n`);
// For this test, we'll create a mock scenario
console.log('⚠️ This is a TEST MODE - using mock data for safety\n');
// Create test backup directory
const testBackupDir = path.join(__dirname, '../temp/test-backup');
await fs.mkdir(testBackupDir, { recursive: true });
// Create test manifest
const testManifest = {
manifest: {
version: '2.0',
created: new Date().toISOString(),
generator: 'Test Script',
format: 'json'
},
backup: {
id: 'test-backup-' + Date.now(),
type: 'full',
timestamp: new Date().toISOString(),
path: testBackupDir,
parent_backup_id: null,
retention_days: 30
},
system: {
hostname: require('os').hostname(),
platform: process.platform,
os_release: require('os').release(),
architecture: require('os').arch()
},
application: {
name: 'PicPeak',
version: require('../package.json').version,
node_version: process.version,
environment: 'test'
},
files: {
count: 0,
total_size: 0,
checksums: {},
manifest: []
},
database: {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backup_file: null,
size: 0,
checksum: null,
tables: {},
row_counts: {}
},
verification: {
total_checksum: null,
file_count_check: 0,
size_check: 0,
integrity_timestamp: new Date().toISOString()
},
metadata: {
test_mode: true
}
};
// Calculate checksum
const crypto = require('crypto');
const manifestCopy = JSON.parse(JSON.stringify(testManifest));
delete manifestCopy.verification.total_checksum;
testManifest.verification.total_checksum = crypto
.createHash('sha256')
.update(JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort()))
.digest('hex');
// Save test manifest
const testManifestPath = path.join(testBackupDir, 'test-manifest.json');
await fs.writeFile(testManifestPath, JSON.stringify(testManifest, null, 2));
options.source = testBackupDir;
options.manifestPath = testManifestPath;
}
// Display restore options
console.log('Restore Options:');
console.log(`- Type: ${options.restoreType}`);
console.log(`- Source: ${options.source}`);
console.log(`- Manifest: ${options.manifestPath}`);
console.log(`- Dry Run: ${options.dryRun ? 'Yes' : 'No'}`);
console.log(`- Force: ${options.force ? 'Yes' : 'No'}`);
console.log('');
// Add S3 config if source is S3
if (options.source.startsWith('s3://')) {
options.s3Config = {
accessKeyId: process.env.BACKUP_S3_ACCESS_KEY,
secretAccessKey: process.env.BACKUP_S3_SECRET_KEY,
region: process.env.BACKUP_S3_REGION || 'us-east-1',
endpoint: process.env.BACKUP_S3_ENDPOINT
};
if (!options.s3Config.accessKeyId || !options.s3Config.secretAccessKey) {
console.error('❌ S3 credentials not configured in environment');
process.exit(1);
}
}
// Confirm before proceeding (unless dry run)
if (!options.dryRun) {
console.log('⚠️ WARNING: This will restore data from the backup!');
console.log('⚠️ Current data may be overwritten!');
console.log('');
console.log('Press Ctrl+C to cancel, or wait 5 seconds to continue...');
await new Promise(resolve => setTimeout(resolve, 5000));
}
console.log('\nStarting restore operation...\n');
// Perform restore
const result = await restoreService.restore(options);
if (options.dryRun) {
console.log('\n=== DRY RUN RESULTS ===\n');
console.log('Validation:');
console.log(`- Valid: ${result.validation.isValid ? '✅ Yes' : '❌ No'}`);
if (result.validation.errors.length > 0) {
console.log('- Errors:');
result.validation.errors.forEach(err => console.log(`${err}`));
}
if (result.validation.warnings.length > 0) {
console.log('- Warnings:');
result.validation.warnings.forEach(warn => console.log(` ⚠️ ${warn}`));
}
console.log('\nDisk Space:');
console.log(`- Required: ${result.spaceCheck.requiredFormatted}`);
console.log(`- Available: ${result.spaceCheck.availableFormatted}`);
console.log(`- Sufficient: ${result.spaceCheck.hasEnoughSpace ? '✅ Yes' : '❌ No'}`);
} else {
console.log('\n=== RESTORE RESULTS ===\n');
console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`);
console.log(`Duration: ${result.duration}s`);
if (result.result) {
console.log('\nItems Restored:');
if (result.result.databaseRestored !== undefined) {
console.log(`- Database: ${result.result.databaseRestored ? '✅' : '❌'}`);
}
if (result.result.filesRestored !== undefined) {
console.log(`- Files: ${result.result.filesRestored}`);
}
if (result.result.errors && result.result.errors.length > 0) {
console.log('- Errors:');
result.result.errors.forEach(err => console.log(`${err}`));
}
}
if (result.verification) {
console.log('\nVerification:');
console.log(`- Valid: ${result.verification.isValid ? '✅ Yes' : '❌ No'}`);
if (result.verification.errors.length > 0) {
console.log('- Errors:');
result.verification.errors.forEach(err => console.log(`${err}`));
}
}
if (result.preRestoreBackup) {
console.log('\nSafety Backup:');
console.log(`- Location: ${result.preRestoreBackup}`);
console.log('- This backup can be used to rollback if needed');
}
}
// Show recent log entries
console.log('\nRecent Log Entries:');
result.logs.slice(-10).forEach(log => {
const icon = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : '️ ';
console.log(`${icon} [${log.timestamp}] ${log.message}`);
});
// Clean up test files
if (options.source && options.source.includes('test-backup')) {
await fs.rmdir(path.dirname(options.source), { recursive: true }).catch(() => {});
}
} catch (error) {
console.error('\n❌ Restore operation failed:', error.message);
// Show logs if available
if (restoreService.restoreLog && restoreService.restoreLog.length > 0) {
console.log('\nError Log:');
restoreService.restoreLog.slice(-10).forEach(log => {
if (log.level === 'error' || log.level === 'warn') {
console.log(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`);
}
});
}
process.exit(1);
}
// Cleanup
await db.destroy();
process.exit(0);
}
// Show help if requested
if (args.includes('--help') || args.includes('-h')) {
console.log(`
PicPeak Restore Service Test
This script tests the restore service functionality with safety checks.
Usage:
node scripts/test-restore-service.js [options]
Options:
--dry-run Perform validation only without actual restore
--force Force restore even with warnings
--type Restore type: full, database, files, selective (default: full)
--source Backup source path or S3 URL
--manifest Path to backup manifest
--help Show this help message
Examples:
# Dry run with automatic backup selection
node scripts/test-restore-service.js --dry-run
# Full restore from specific backup
node scripts/test-restore-service.js --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Database-only restore with force
node scripts/test-restore-service.js --type database --force --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Restore from S3
node scripts/test-restore-service.js --source s3://my-bucket/backups/2024-01-20 --manifest s3://my-bucket/backups/2024-01-20/manifest.json
Safety Features:
- Pre-restore validation checks compatibility and warns about potential issues
- Automatic pre-restore backup is created (unless skipped)
- Post-restore verification ensures data integrity
- Rollback capability if restore fails
- Detailed logging of all operations
`);
process.exit(0);
}
// Run the test
testRestore();
+293 -30
View File
@@ -12,6 +12,7 @@ logger.info('Server starting up', {
timestamp: new Date().toISOString()
});
const fs = require('fs');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@@ -24,14 +25,22 @@ const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser');
const {
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('./src/utils/tokenUtils');
// Import routes
const authRoutes = require('./src/routes/auth-enhanced');
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
const secureImagesRoutes = require('./src/routes/secureImages');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -41,25 +50,79 @@ const PORT = process.env.PORT || 3000;
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
// Security middleware with custom CSP
// In native HTTP installs, do NOT force HTTPS for subresources.
const enableHsts = process.env.ENABLE_HSTS === 'true';
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
'https://www.google.com',
'https://www.gstatic.com'
],
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
fontSrc: ["'self'", "https:", "data:"], // Web fonts
objectSrc: ["'none'"], // Disable plugins
mediaSrc: ["'self'"], // Audio/video
frameSrc: ["'self'", 'https://www.google.com'],
};
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
if (enableHsts) {
// In helmet, an empty array enables the directive
cspDirectives.upgradeInsecureRequests = [];
}
app.use(cookieParser());
app.use((req, res, next) => {
if (req.headers.authorization) {
return next();
}
const path = req.path || '';
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req, slug);
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
const isGalleryRequest = Boolean(slugMatch)
|| path.startsWith('/api/gallery')
|| path.startsWith('/gallery')
|| path.startsWith('/api/secure-images');
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
if (isAdminRequest) {
if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (isGalleryRequest) {
if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
} else if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
}
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
connectSrc: ["'self'"], // API connections
fontSrc: ["'self'", "https:", "data:"], // Web fonts
objectSrc: ["'none'"], // Disable plugins
mediaSrc: ["'self'"], // Audio/video
frameSrc: ["'none'"], // Disable iframes
},
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
useDefaults: false,
directives: cspDirectives,
},
hsts: {
hsts: enableHsts ? {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
} : false,
permittedCrossDomainPolicies: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
}));
@@ -71,14 +134,14 @@ app.use((req, res, next) => {
next();
});
// CORS configuration
// CORS configuration (apply only to API routes)
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
// In development, also allow localhost origins
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push(
@@ -88,23 +151,166 @@ const corsOptions = {
'http://localhost:3000' // Direct backend access
);
}
// Allow requests with no origin (like mobile apps or curl)
// Allow requests with no origin (like curl) and allow-listed origins
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
// Do not error globally; just omit CORS headers on disallowed origins
callback(null, false);
}
},
credentials: true
};
app.use(cors(corsOptions));
// Only attach CORS to API endpoints, not static assets
app.use('/api', cors(corsOptions));
// Handle preflight explicitly for API paths
app.options('/api/*', cors(corsOptions));
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
function composeInlineStyles(payload) {
const { branding } = payload;
const cssSegments = [];
cssSegments.push(`:root {
--brand-primary: ${branding.colors.primary};
--brand-accent: ${branding.colors.accent};
--brand-background: ${branding.colors.background};
--brand-text: ${branding.colors.text};
}`);
if (payload.baseCss) {
cssSegments.push(payload.baseCss);
}
if (payload.css) {
cssSegments.push(`/* Custom styles */\n${payload.css}`);
}
return cssSegments.join('\n\n');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
const tagline = branding.companyTagline
? `<p class="brand-tagline">${branding.companyTagline}</p>`
: '';
return `<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#features">${'Features'}</a>
<a href="#workflow">${'Workflow'}</a>
<a href="#collections">${'Collections'}</a>
<a href="#stories">${'Stories'}</a>
<a href="#contact">${'Contact'}</a>
</nav>
</div>
</header>`;
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const footerNote = branding.footerText
? `<p>${branding.footerText}</p>`
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
const supportLink = branding.supportEmail
? `<a href="mailto:${branding.supportEmail}">Support</a>`
: '';
const legalLinks = `
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
${supportLink}
`;
return `<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-links">
${legalLinks}
</div>
</div>
</footer>`;
}
function buildPublicSiteDocument(payload) {
const inlineStyles = composeInlineStyles(payload);
const header = renderBrandHeader(payload.branding);
const footer = renderBrandFooter(payload.branding);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
${header}
<main class="site-main">
${payload.html}
</main>
${footer}
</div>
</body>
</html>`;
}
async function handlePublicSiteRequest(req, res, next) {
try {
const payload = await getPublicSitePayload();
if (!payload.enabled) {
res.redirect(302, '/admin/login');
return;
}
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
res.status(304).end();
return;
}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
res.setHeader('ETag', payload.etag);
res.setHeader('Vary', 'Accept-Encoding');
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
res.status(200).send(document);
} catch (error) {
logger.error('Failed to render public site', { error: error.message });
next();
}
}
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
@@ -118,10 +324,24 @@ async function initializeRateLimiters() {
}
// Note: Rate limiters will be initialized after database connection
app.use(express.json({ limit: '10gb' }));
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -142,6 +362,7 @@ const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
@@ -198,24 +419,66 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
app.use('/api/secure-images', secureImagesRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
logger.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// Optional: Serve built frontend (native installs)
try {
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
const indexPath = path.join(frontendDir, 'index.html');
// Auto-serve when dist exists unless explicitly disabled
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
if (shouldServe) {
logger.info(`Serving frontend from ${frontendDir}`);
// Serve pre-built assets
app.use(express.static(frontendDir));
// Landing page handler or SPA fallback
app.get('/', handlePublicSiteRequest, (req, res) => {
res.sendFile(indexPath);
});
// SPA fallback for admin + gallery routes
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath);
});
} else {
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
app.get('/', handlePublicSiteRequest, (req, res) => {
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
});
}
} catch (e) {
logger.warn('Failed to enable frontend static serving', { error: e.message });
}
// 404 handler for undefined API routes
app.use('/api', notFoundHandler);
// Global error handler (must be last)
app.use(errorHandler);
// Initialize services
async function startServer() {
@@ -0,0 +1,107 @@
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return {
db: mockDb,
logActivity: jest.fn(),
};
});
jest.mock('../utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const { db } = require('../database/db');
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const buildPublicSiteRows = (overrides = {}) => ([
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
]);
const buildBrandingRows = (overrides = {}) => ([
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
primaryColor: '#2563eb',
accentColor: '#1d4ed8',
backgroundColor: '#f8fafc',
textColor: '#0f172a'
}) }
]);
describe('publicSiteService', () => {
beforeEach(() => {
clearPublicSiteCache();
jest.clearAllMocks();
});
it('sanitizes stored HTML by stripping script tags', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.enabled).toBe(true);
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
expect(payload.html).not.toContain('<script');
expect(payload.baseCss.length).toBeGreaterThan(0);
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
});
it('sanitizes custom CSS and removes dangerous patterns', async () => {
const publicSiteRows = buildPublicSiteRows({
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
});
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.css).toContain('body { color: blue; }');
expect(payload.css).not.toContain('@import');
expect(payload.css).not.toContain('javascript:');
// Client-side util should match server sanitization expectations
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
expect(clientSanitized).not.toContain('@import');
expect(clientSanitized).not.toContain('javascript:');
});
it('injects branding tokens into the rendered payload', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
const brandingRows = buildBrandingRows({
companyName: 'Aurora Collective',
companyTagline: 'Modern photography for timeless celebrations.',
supportEmail: 'studio@aurora.co',
logoUrl: '/uploads/logos/aurora.png',
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#1d4ed8',
backgroundColor: '#fafafa',
textColor: '#171717'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.html).toContain('Aurora Collective');
expect(payload.html).toContain('Modern photography for timeless celebrations.');
expect(payload.html).toContain('studio@aurora.co');
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762');
});
});
+255
View File
@@ -0,0 +1,255 @@
/**
* Centralized Multer Configuration Factory
* Provides pre-configured multer instances for different upload scenarios
*
* @module config/multerConfig
*/
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { validateFileType } = require('../utils/fileSecurityUtils');
/**
* Get the storage path from environment or default
* @returns {string}
*/
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Default allowed MIME types for different upload types
*/
const ALLOWED_TYPES = {
photos: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
videos: ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
media: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
logos: ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
favicons: ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'],
documents: ['application/pdf', 'text/plain']
};
/**
* Default file size limits (in bytes)
*/
const SIZE_LIMITS = {
small: 1 * 1024 * 1024, // 1MB
medium: 5 * 1024 * 1024, // 5MB
large: 50 * 1024 * 1024, // 50MB
xlarge: 500 * 1024 * 1024, // 500MB
huge: 10 * 1024 * 1024 * 1024 // 10GB (for large videos)
};
/**
* Create a disk storage configuration
*
* @param {Object} options - Storage options
* @param {string} options.subdir - Subdirectory within storage path
* @param {Function} [options.filename] - Custom filename generator
* @param {boolean} [options.useTemp] - Use temp directory instead
* @returns {multer.StorageEngine}
*/
const createDiskStorage = (options = {}) => {
const { subdir, filename, useTemp = false } = options;
return multer.diskStorage({
destination: async (req, file, cb) => {
try {
let uploadDir;
if (useTemp) {
uploadDir = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
} else {
uploadDir = path.join(getStoragePath(), subdir || 'uploads');
}
// Create directory synchronously to prevent race conditions
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: filename || ((req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '_');
cb(null, `${baseName}-${uniqueSuffix}${ext}`);
})
});
};
/**
* Create a file filter function
*
* @param {string[]} allowedTypes - Array of allowed MIME types
* @param {Object} [options] - Filter options
* @param {boolean} [options.validateMagicNumbers] - Whether to validate file magic numbers
* @param {string[]} [options.skipMagicValidation] - MIME types to skip magic number validation for
* @returns {Function} Multer file filter function
*/
const createFileFilter = (allowedTypes, options = {}) => {
const { validateMagicNumbers = true, skipMagicValidation = [] } = options;
return (req, file, cb) => {
// Basic MIME type check
if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error(`File type ${file.mimetype} not allowed. Allowed types: ${allowedTypes.join(', ')}`));
}
// Validate file type with magic numbers (if enabled and not skipped)
if (validateMagicNumbers && !skipMagicValidation.includes(file.mimetype)) {
if (validateFileType && !validateFileType(file.originalname, file.mimetype, allowedTypes)) {
return cb(new Error('File content does not match file type'));
}
}
cb(null, true);
};
};
/**
* Create a multer instance for photo uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createPhotoUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({ useTemp: true }),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.huge,
files: options.maxFiles || 2000,
fieldSize: 10 * 1024 * 1024,
parts: 10000,
headerPairs: 2000
},
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
validateMagicNumbers: true
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for logo uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createLogoUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({
subdir: 'uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `logo-${Date.now()}${ext}`);
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.medium
},
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
skipMagicValidation: ['image/svg+xml']
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for favicon uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createFaviconUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({
subdir: 'uploads/favicons',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `favicon-${Date.now()}${ext}`);
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.small
},
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for gallery user uploads
*
* @param {string} destDir - Destination directory
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createGalleryUploader = (destDir, options = {}) => {
const defaults = {
dest: destDir,
limits: {
fileSize: options.maxSize || SIZE_LIMITS.large,
files: options.maxFiles || 10
},
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
};
return multer({ ...defaults, ...options });
};
/**
* Create a custom multer instance
*
* @param {Object} config - Full multer configuration
* @returns {multer.Multer}
*/
const createCustomUploader = (config) => {
return multer(config);
};
/**
* Upload timeout middleware
*
* @param {number} [timeout=300000] - Timeout in milliseconds (default 5 minutes)
* @returns {Function} Express middleware
*/
const uploadTimeoutMiddleware = (timeout = 300000) => {
return (req, res, next) => {
req.setTimeout(timeout, () => {
console.error('Upload request timed out');
if (!res.headersSent) {
res.status(408).json({ error: 'Upload request timed out' });
}
});
res.setTimeout(timeout, () => {
console.error('Upload response timed out');
});
next();
};
};
module.exports = {
// Pre-configured uploaders
createPhotoUploader,
createLogoUploader,
createFaviconUploader,
createGalleryUploader,
createCustomUploader,
// Building blocks for custom configurations
createDiskStorage,
createFileFilter,
// Middleware
uploadTimeoutMiddleware,
// Constants
ALLOWED_TYPES,
SIZE_LIMITS
};
+690
View File
@@ -0,0 +1,690 @@
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
const DEFAULT_PUBLIC_SITE_HTML = `
<section class="hero" id="welcome">
<div class="hero__inner">
<span class="hero__badge">PicPeak Showcase</span>
<h1>Share the story of {{company_name}}</h1>
<p class="hero__lead">{{company_tagline}}</p>
<div class="hero__cta">
<a href="#features" class="button button--primary">Explore Features</a>
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
</div>
<dl class="hero__stats">
<div>
<dt>Private invites</dt>
<dd>Secure links for every guest</dd>
</div>
<div>
<dt>Curated delivery</dt>
<dd>Highlight every favourite instantly</dd>
</div>
<div>
<dt>Fully branded</dt>
<dd>Colours, typography, and logo that match you</dd>
</div>
</dl>
</div>
<div class="hero__visual">
<article class="deck deck--primary">
<header class="deck__header">
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
<span class="deck__title">PicPeak Gallery</span>
</header>
<ul class="deck__list">
<li>Guided cover stories</li>
<li>Guest uploads with approvals</li>
<li>Protected high-res downloads</li>
</ul>
</article>
<article class="deck deck--secondary">
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
<p class="deck__author">— Studio Miraval</p>
</article>
</div>
</section>
<section class="features" id="features">
<div class="section-head">
<span class="section-badge">Why teams pick PicPeak</span>
<h2>Design-first galleries with the workflow you already love</h2>
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
</div>
<div class="feature-grid">
<article>
<h3>Beautiful by default</h3>
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
</article>
<article>
<h3>Guided storytelling</h3>
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
</article>
<article>
<h3>Secure sharing</h3>
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
</article>
</div>
</section>
<section class="workflow" id="workflow">
<div class="workflow__content">
<h2>Launch in minutes</h2>
<ol class="workflow__steps">
<li>
<h4>Brand it once</h4>
<p>PicPeak automatically applies your logo, colours, and support details.</p>
</li>
<li>
<h4>Curate sections</h4>
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
</li>
<li>
<h4>Share confidently</h4>
<p>Send a single link that greets guests before they enter their private gallery.</p>
</li>
</ol>
</div>
<div class="workflow__media">
<figure class="workflow__browser">
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
<figcaption>PicPeak dashboard &mdash; trusted by studios worldwide.</figcaption>
</figure>
</div>
</section>
<section class="collections" id="collections">
<div class="section-head">
<span class="section-badge">Showcase highlights</span>
<h2>Curated sample galleries that mirror your client experience</h2>
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
</div>
<div class="collection-showcase">
<article>
<h3>Signature Galleries</h3>
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
</article>
<article>
<h3>Welcome timelines</h3>
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
</article>
</div>
</section>
<section class="stories" id="stories">
<div class="section-head section-head--center">
<span class="section-badge">Client notes</span>
<h2>Experiences that keep guests coming back</h2>
</div>
<div class="story-grid">
<figure>
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
<figcaption>— Harper &amp; Elias</figcaption>
</figure>
<figure>
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
<figcaption>— Jordan Rivera, Photographer</figcaption>
</figure>
</div>
</section>
<section class="cta" id="contact">
<div class="cta__inner">
<div>
<h2>Ready to welcome your guests?</h2>
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
</div>
<div class="cta__actions">
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
<a href="#features" class="button button--ghost">Review features</a>
</div>
</div>
</section>
<footer class="site-footer" id="legal">
<div class="footer-inner">
<div>
<h2>{{company_name}}</h2>
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
</div>
<div class="footer-links">
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
<a href="mailto:{{support_email}}">Support</a>
</div>
</div>
</footer>
`;
const DEFAULT_PUBLIC_SITE_CSS = `
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
color: var(--brand-text);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.site-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
}
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: contain;
background: rgba(148, 163, 184, 0.12);
padding: 6px;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand-label {
margin: 0;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--brand-text);
}
.brand-tagline {
margin: 0;
font-size: 0.85rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav {
display: flex;
gap: 1rem;
font-size: 0.95rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav a {
position: relative;
padding: 0.25rem 0;
}
.site-nav a::after {
content: '';
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
background: transparent;
transition: background 0.2s ease;
}
.site-nav a:hover::after {
background: var(--brand-primary);
}
.site-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rem;
padding: 2.5rem 1.5rem 4rem;
}
@media (min-width: 960px) {
.site-main {
padding: 3rem 0 5rem;
gap: 5rem;
}
.hero,
.features,
.workflow,
.collections,
.stories,
.cta {
max-width: 1100px;
margin: 0 auto;
}
}
.hero {
display: grid;
gap: 2.5rem;
align-items: center;
}
@media (min-width: 960px) {
.hero {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
}
.hero__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.hero__badge {
display: inline-flex;
align-items: center;
padding: 0.55rem 0.9rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.18);
color: var(--brand-primary);
}
.hero h1 {
margin: 0;
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
letter-spacing: -0.02em;
line-height: 1.08;
}
.hero__lead {
margin: 0;
max-width: 32rem;
color: rgba(15, 23, 42, 0.72);
font-size: 1.05rem;
line-height: 1.6;
}
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.hero__stats {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
margin: 0;
padding: 0;
}
.hero__stats dt {
font-weight: 600;
color: var(--brand-text);
}
.hero__stats dd {
margin: 0.35rem 0 0;
color: rgba(15, 23, 42, 0.6);
font-size: 0.95rem;
}
.hero__visual {
display: grid;
gap: 1.5rem;
}
.deck {
border-radius: 20px;
padding: 1.75rem;
background: #fff;
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
display: grid;
gap: 1.35rem;
}
.deck--primary {
border-color: rgba(34, 197, 94, 0.2);
}
.deck--secondary {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
}
.deck__header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.deck__logo {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(34, 197, 94, 0.12);
padding: 6px;
}
.deck__title {
font-weight: 600;
letter-spacing: -0.01em;
}
.deck__list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.65rem;
color: rgba(15, 23, 42, 0.68);
}
.deck__quote {
margin: 0;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.78);
}
.deck__author {
margin: 0;
font-weight: 600;
color: var(--brand-text);
}
.section-head {
display: grid;
gap: 1rem;
max-width: 640px;
}
.section-head--center {
text-align: center;
margin: 0 auto;
}
.section-badge {
display: inline-flex;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.14);
color: var(--brand-primary);
}
.section-head h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.6rem);
letter-spacing: -0.018em;
}
.section-head p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.feature-grid {
display: grid;
gap: 1.5rem;
margin-top: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.feature-grid article {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
}
.workflow {
display: grid;
gap: 2rem;
align-items: center;
}
@media (min-width: 960px) {
.workflow {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
.workflow__steps {
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 1.5rem;
}
.workflow__steps h4 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
color: var(--brand-text);
}
.workflow__steps p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.workflow__browser {
margin: 0;
background: rgba(15, 23, 42, 0.05);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.1);
padding: 2rem;
text-align: center;
color: rgba(15, 23, 42, 0.55);
font-size: 0.85rem;
}
.collection-showcase {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.collection-showcase article {
background: rgba(255, 255, 255, 0.92);
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 1.5rem;
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
}
.story-grid {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.story-grid figure {
margin: 0;
padding: 1.75rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
}
.story-grid blockquote {
margin: 0 0 1.2rem;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.8);
}
.story-grid figcaption {
font-weight: 600;
color: rgba(15, 23, 42, 0.7);
}
.cta {
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
color: #fff;
border-radius: 28px;
padding: clamp(2.5rem, 5vw, 3.5rem);
}
.cta__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 720px;
}
.cta__inner h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.5rem);
}
.cta__inner p {
margin: 0;
font-size: 1.05rem;
opacity: 0.95;
}
.cta__actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1.75rem;
border-radius: 999px;
font-weight: 600;
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
border: 1px solid transparent;
}
.button:hover {
transform: translateY(-2px);
}
.button--primary {
background: var(--brand-primary);
color: #fff;
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
}
.button--primary:hover {
background: var(--brand-accent);
}
.button--ghost {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.45);
color: inherit;
}
.site-footer {
padding: 3rem 1.5rem;
background: rgba(15, 23, 42, 0.05);
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.footer-inner h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.footer-inner p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
line-height: 1.6;
}
.footer-links {
display: flex;
flex-direction: column;
gap: 0.65rem;
font-weight: 600;
color: var(--brand-primary);
}
.footer-links a {
color: inherit;
}
.footer-links a:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.site-nav {
display: none;
}
.hero__visual {
grid-template-columns: minmax(0, 1fr);
}
.workflow {
grid-template-columns: minmax(0, 1fr);
}
.cta__inner {
gap: 1.5rem;
}
}
`;
module.exports = {
DEFAULT_PUBLIC_SITE_TITLE,
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
};
+280 -6
View File
@@ -1,5 +1,28 @@
const fs = require('fs');
const path = require('path');
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs)
try {
const isPostgres = knexConfig && knexConfig.client === 'pg';
if (!isPostgres && knexConfig && knexConfig.connection) {
const filename = typeof knexConfig.connection === 'object'
? knexConfig.connection.filename
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
if (filename && typeof filename === 'string') {
const dir = path.dirname(filename);
if (dir && dir !== '.') {
fs.mkdirSync(dir, { recursive: true });
}
}
}
} catch (e) {
// Non-fatal: log and continue; SQLite will fail later if still missing
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
}
// Create database connection with built-in retry logic
const db = knex(knexConfig);
@@ -22,7 +45,7 @@ async function withRetry(queryFn, retries = MAX_RETRIES) {
);
if (isConnectionError && i < retries - 1) {
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
logger.info(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
continue;
}
@@ -41,18 +64,30 @@ async function initializeDatabase() {
table.string('event_type').notNullable();
table.string('event_name').notNullable();
table.date('event_date').notNullable();
table.string('customer_name');
table.string('customer_email');
table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable();
table.string('password_hash').notNullable();
table.text('welcome_message');
table.text('color_theme');
table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true);
table.boolean('is_archived').defaultTo(false);
table.string('archive_path');
table.datetime('archived_at');
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id');
table.boolean('allow_downloads').defaultTo(true);
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
table.boolean('require_password').defaultTo(true);
});
} else {
// Check if color_theme needs to be updated to TEXT type
@@ -69,12 +104,16 @@ async function initializeDatabase() {
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
customer_name TEXT,
customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
@@ -82,20 +121,82 @@ async function initializeDatabase() {
archive_path TEXT,
archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER
upload_category_id INTEGER,
allow_downloads BOOLEAN DEFAULT 1,
disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT,
hero_photo_id INTEGER,
require_password BOOLEAN DEFAULT 1
)
`);
await db.raw('INSERT INTO events_new SELECT * FROM events');
const pragmaRows = await db.raw("PRAGMA table_info('events')");
const existingColumns = pragmaRows.map(row => row.name);
const selectColumns = existingColumns.map((col) => {
switch (col) {
case 'allow_user_uploads':
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
case 'upload_category_id':
return "upload_category_id";
case 'allow_downloads':
return "COALESCE(allow_downloads, 1) as allow_downloads";
case 'disable_right_click':
return "COALESCE(disable_right_click, 0) as disable_right_click";
case 'watermark_downloads':
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
case 'watermark_text':
return 'watermark_text';
case 'hero_photo_id':
return 'hero_photo_id';
case 'require_password':
return 'COALESCE(require_password, 1) as require_password';
default:
return col;
}
});
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
await db.raw('DROP TABLE events');
await db.raw('ALTER TABLE events_new RENAME TO events');
} catch (error) {
// If the migration fails, it might already have been applied
console.log('Color theme migration may have already been applied');
logger.debug('Color theme migration may have already been applied');
}
}
}
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
if (!hasShareTokenColumn) {
await db.schema.table('events', (table) => {
table.string('share_token').unique();
});
}
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
if (!hasHostNameColumn) {
await db.schema.table('events', (table) => {
table.string('host_name');
});
}
try {
const eventsWithoutToken = await db('events')
.whereNull('share_token')
.select('id', 'share_link');
for (const event of eventsWithoutToken) {
const token = extractShareToken(event.share_link);
if (token) {
await db('events')
.where({ id: event.id })
.update({ share_token: token });
}
}
} catch (error) {
logger.warn('Share token backfill skipped', { error: error.message });
}
// Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) {
@@ -107,6 +208,7 @@ async function initializeDatabase() {
table.string('thumbnail_path');
table.string('type').notNullable(); // 'collage' or 'individual'
table.integer('size_bytes');
table.string('uploaded_by').defaultTo('admin');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0);
@@ -137,11 +239,24 @@ async function initializeDatabase() {
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
table.json('email_data');
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('scheduled_at').defaultTo(db.fn.now());
table.datetime('sent_at');
table.text('error_message');
table.integer('retry_count').defaultTo(0);
});
} else {
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
if (!hasCreatedAt) {
await db.schema.alterTable('email_queue', (table) => {
table.datetime('created_at').defaultTo(db.fn.now());
});
try {
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
} catch (updateError) {
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
}
}
}
// Admin users table
@@ -153,9 +268,13 @@ async function initializeDatabase() {
table.string('email').unique().notNullable();
table.string('password_hash').notNullable();
table.boolean('is_active').defaultTo(true);
table.boolean('must_change_password').defaultTo(false);
table.datetime('password_changed_at');
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('updated_at').defaultTo(db.fn.now());
table.datetime('last_login');
table.string('last_login_ip');
table.string('language', 2).defaultTo('en');
});
} else {
// Check if updated_at column exists
@@ -167,6 +286,69 @@ async function initializeDatabase() {
// Set default value for existing rows
await db('admin_users').update({ updated_at: new Date() });
}
// Check if must_change_password column exists
const hasMustChangePassword = await db.schema.hasColumn('admin_users', 'must_change_password');
if (!hasMustChangePassword) {
await db.schema.table('admin_users', (table) => {
table.boolean('must_change_password').defaultTo(false);
});
}
// Check if password_changed_at column exists
const hasPasswordChangedAt = await db.schema.hasColumn('admin_users', 'password_changed_at');
if (!hasPasswordChangedAt) {
await db.schema.table('admin_users', (table) => {
table.datetime('password_changed_at');
});
}
// Check if last_login_ip column exists
const hasLastLoginIp = await db.schema.hasColumn('admin_users', 'last_login_ip');
if (!hasLastLoginIp) {
await db.schema.table('admin_users', (table) => {
table.string('last_login_ip');
});
}
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
if (!hasLanguage) {
await db.schema.table('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
}
}
// Token revocation tables
const hasRevokedTokensTable = await db.schema.hasTable('revoked_tokens');
if (!hasRevokedTokensTable) {
await db.schema.createTable('revoked_tokens', (table) => {
table.increments('id').primary();
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
table.integer('user_id').nullable(); // User who owned the token
table.string('token_type', 20); // admin, gallery, etc.
table.timestamp('revoked_at').defaultTo(db.fn.now());
table.timestamp('expires_at').notNullable(); // When token would have expired
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
// Indexes for performance
table.index('token_id');
table.index('user_id');
table.index('expires_at'); // For cleanup
});
}
const hasUserTokenRevocationsTable = await db.schema.hasTable('user_token_revocations');
if (!hasUserTokenRevocationsTable) {
await db.schema.createTable('user_token_revocations', (table) => {
table.integer('user_id').primary();
table.timestamp('revoked_at').notNullable();
table.string('reason', 100);
// Index for quick lookups
table.index('revoked_at');
});
}
// Email configuration table
@@ -210,6 +392,18 @@ async function initializeDatabase() {
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
const defaultLanguageSetting = await db('app_settings')
.where('setting_key', 'default_language')
.first();
if (!defaultLanguageSetting) {
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date(),
});
}
// Activity logs table
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
@@ -234,6 +428,86 @@ async function initializeDatabase() {
});
}
}
await ensureGlobalCategories();
}
// Ensure photo categories exist for new deployments
async function ensureGlobalCategories() {
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
if (!hasPhotoCategoriesTable) {
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
table.unique(['slug', 'event_id']);
});
}
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
if (!hasCategoryIdColumn) {
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
}
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (!hasCmsPagesTable) {
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
if (categoryCount === 0) {
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true },
];
await db('photo_categories').insert(defaultCategories);
}
const cmsPages = await db('cms_pages').select('slug');
const existingSlugs = cmsPages.map((page) => page.slug);
const defaultPages = [
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
if (!existingSlugs.includes(page.slug)) {
await db('cms_pages').insert(page);
}
}
}
// Helper function to log activities
@@ -248,8 +522,8 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
event_id: eventId
});
} catch (error) {
console.error('Failed to log activity:', error);
logger.error('Failed to log activity:', { error: error.message });
}
}
module.exports = { db, initializeDatabase, logActivity, withRetry };
module.exports = { db, initializeDatabase, logActivity, withRetry };
-167
View File
@@ -1,167 +0,0 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
/**
* Enhanced admin authentication middleware with revocation checking
*/
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
};
req.token = token; // Store token for potential revocation
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Enhanced gallery authentication middleware with revocation checking
*/
async function galleryAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
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({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
req.token = token;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
// Export other middleware functions from original file...
module.exports = {
adminAuth,
galleryAuth,
// ... other exports
};
-238
View File
@@ -1,238 +0,0 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
/**
* Enhanced admin authentication middleware
* Adds additional security checks beyond basic JWT validation
*/
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload; // Extract payload when using complete: true
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
// Optional: Reject if IP doesn't match
// return res.status(401).json({ error: 'Invalid token' });
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
};
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Enhanced gallery authentication middleware
*/
async function galleryAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
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({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Photo access authentication
* Validates both admin and gallery tokens for photo access
*/
async function photoAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
req.auth = { type: 'admin', user: admin };
} else if (decoded.type === 'gallery') {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// For gallery tokens, ensure they can only access their event's photos
req.auth = { type: 'gallery', event: event };
} else {
return res.status(403).json({ error: 'Invalid token type' });
}
next();
} catch (error) {
logger.error('Photo auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Verify gallery access for specific operations
*/
async function verifyGalleryAccess(req, res, next) {
try {
if (!req.auth) {
return res.status(401).json({ error: 'Authentication required' });
}
const { eventId } = req.params;
// Admins can access any gallery
if (req.auth.type === 'admin') {
return next();
}
// Gallery tokens can only access their own event
if (req.auth.type === 'gallery') {
if (req.auth.event.id !== parseInt(eventId)) {
return res.status(403).json({ error: 'Access denied' });
}
return next();
}
res.status(403).json({ error: 'Access denied' });
} catch (error) {
res.status(500).json({ error: 'Access verification failed' });
}
}
module.exports = {
adminAuth,
galleryAuth,
photoAuth,
verifyGalleryAccess
};
+260 -58
View File
@@ -1,85 +1,287 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware with revocation checking
*/
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth attempt without token', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (jwtError) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('JWT validation failed', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
error: jwtError.name,
message: jwtError.message,
timestamp: new Date().toISOString()
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
if (jwtError.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth failed - user not found or inactive', {
ip: clientIp,
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString()
tokenType: decoded.type
});
return res.status(401).json({ error: 'Invalid token' });
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
req.admin = admin;
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.password_changed_at',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request (enhanced with role)
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name
};
req.token = token; // Store token for potential revocation
next();
} catch (error) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.error('Admin auth middleware error', {
ip: clientIp,
path: req.path,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
res.status(401).json({ error: 'Invalid token' });
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
module.exports = { adminAuth };
/**
* Enhanced gallery authentication middleware with revocation checking
*/
async function galleryAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
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({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
req.token = token;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Photo access authentication
* Validates both admin and gallery tokens for photo access
*/
async function photoAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
req.auth = { type: 'admin', user: admin };
} else if (decoded.type === 'gallery') {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// For gallery tokens, ensure they can only access their event's photos
req.auth = { type: 'gallery', event: event };
} else {
return res.status(403).json({ error: 'Invalid token type' });
}
next();
} catch (error) {
logger.error('Photo auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Verify gallery access for specific operations
*/
async function verifyGalleryAccess(req, res, next) {
try {
if (!req.auth) {
return res.status(401).json({ error: 'Authentication required' });
}
const { eventId } = req.params;
// Admins can access any gallery
if (req.auth.type === 'admin') {
return next();
}
// Gallery tokens can only access their own event
if (req.auth.type === 'gallery') {
if (req.auth.event.id !== parseInt(eventId)) {
return res.status(403).json({ error: 'Access denied' });
}
return next();
}
res.status(403).json({ error: 'Access denied' });
} catch (error) {
res.status(500).json({ error: 'Access verification failed' });
}
}
module.exports = {
adminAuth,
galleryAuth,
photoAuth,
verifyGalleryAccess
};
+183
View File
@@ -0,0 +1,183 @@
/**
* Global error handler middleware.
* Catches all errors and returns standardized responses.
* Distinguishes between operational errors (expected) and programming errors (bugs).
*/
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
/**
* Determines if an error is operational (expected) or a programming error (bug).
* Operational errors are expected failures like validation errors, not found, etc.
* Programming errors are bugs that should be logged and investigated.
*
* @param {Error} err - The error to check
* @returns {boolean} True if operational error
*/
const isOperationalError = (err) => {
return err instanceof AppError && err.isOperational;
};
/**
* Formats error for development environment (includes stack trace).
*
* @param {Error} err - The error object
* @returns {Object} Formatted error response
*/
const formatDevError = (err) => {
return {
error: err.message,
code: err.code || 'INTERNAL_ERROR',
stack: err.stack,
...(err.details && { details: err.details }),
...(err.field && { field: err.field })
};
};
/**
* Formats error for production environment (hides sensitive details).
*
* @param {Error} err - The error object
* @param {boolean} isOperational - Whether this is an operational error
* @returns {Object} Formatted error response
*/
const formatProdError = (err, isOperational) => {
// For operational errors, show the message
if (isOperational) {
return {
error: err.message,
code: err.code || 'ERROR',
...(err.details && { details: err.details }),
...(err.field && { field: err.field })
};
}
// For programming errors, hide details
return {
error: 'An unexpected error occurred',
code: 'INTERNAL_ERROR'
};
};
/**
* Handles specific error types and converts them to AppError format.
*
* @param {Error} err - The error to handle
* @returns {Error} Converted error or original error
*/
const handleKnownErrors = (err) => {
// Handle Knex/Database errors
if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') {
const { AppError } = require('../utils/errors');
const error = new AppError('A record with this value already exists', 409, 'DUPLICATE_ENTRY');
error.isOperational = true;
return error;
}
// Handle JSON parsing errors
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Invalid JSON in request body');
}
// Handle multer file upload errors
if (err.code === 'LIMIT_FILE_SIZE') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('File size exceeds the maximum allowed limit');
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Unexpected file field');
}
return err;
};
/**
* Global error handler middleware.
* Must be registered last, after all routes.
*
* @param {Error} err - The error object
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const errorHandler = (err, req, res, next) => {
// If headers already sent, delegate to Express default handler
if (res.headersSent) {
return next(err);
}
// Convert known error types
const error = handleKnownErrors(err);
// Determine error status code
const statusCode = error.statusCode || error.status || 500;
const operational = isOperationalError(error);
// Log the error
const logContext = {
url: req.originalUrl,
method: req.method,
ip: req.ip,
statusCode,
errorCode: error.code,
operational,
...(req.admin && { adminId: req.admin.id }),
...(req.gallerySlug && { gallerySlug: req.gallerySlug })
};
if (operational) {
// Operational errors are expected, log at warn level
logger.warn('Operational error', {
...logContext,
message: error.message
});
} else {
// Programming errors are bugs, log at error level with stack
logger.error('Unhandled error', {
...logContext,
message: error.message,
stack: error.stack
});
}
// Format and send response
const isDev = process.env.NODE_ENV === 'development';
const response = isDev ? formatDevError(error) : formatProdError(error, operational);
res.status(statusCode).json(response);
};
/**
* 404 handler for undefined routes.
* Should be registered after all routes but before errorHandler.
*
* @param {Request} req - Express request object
* @param {Response} res - Express response object
* @param {Function} next - Express next function
*/
const notFoundHandler = (req, res, next) => {
const { NotFoundError } = require('../utils/errors');
next(new NotFoundError('Route', req.originalUrl));
};
/**
* Async handler that catches unhandled promise rejections.
* Use this to wrap async route handlers.
*
* @param {Function} fn - Async function to wrap
* @returns {Function} Wrapped function
*/
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = {
errorHandler,
notFoundHandler,
asyncHandler,
isOperationalError
};
+6 -3
View File
@@ -24,7 +24,10 @@ async function getRateLimitSettings() {
.first();
if (settings && settings.setting_value) {
return JSON.parse(settings.setting_value);
// setting_value is already a JSON object in PostgreSQL
return typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
}
// Default settings
@@ -120,8 +123,8 @@ async function recordAction(identifier, eventId, actionType) {
function feedbackRateLimit(actionType) {
return async (req, res, next) => {
try {
// Extract event ID from params or body
const eventId = req.params.eventId || req.body?.event_id;
// Extract event ID from params, body or event object (set by verifyGalleryAccess)
const eventId = req.params.eventId || req.body?.event_id || req.event?.id;
if (!eventId) {
return res.status(400).json({ error: 'Event ID required' });
}
+101 -14
View File
@@ -1,38 +1,125 @@
const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const requestedSlug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => {
return await db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await withRetry(async () => {
return await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
});
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
} catch (error) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw error;
}
}
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
event = await withRetry(async () => {
return await db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
// Verify the token's eventId matches
if (event && event.id !== decoded.eventId) {
return res.status(403).json({ error: 'Token does not match requested gallery' });
}
} else {
// Fallback to using eventId from token
event = await withRetry(async () => {
return await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
}
if (!event) {
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' });
}
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column
timestamp: Date.now()
};
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = {
verifyGalleryAccess
};
};
+242
View File
@@ -0,0 +1,242 @@
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
*/
const { db } = require('../database/db');
const { ForbiddenError } = require('../utils/errors');
const logger = require('../utils/logger');
// Cache for role permissions (refreshed periodically)
let permissionCache = new Map();
let cacheLastUpdated = 0;
const CACHE_TTL = 60000; // 1 minute
/**
* Refresh permission cache from database
* Handles upgrade scenario where RBAC tables may not exist yet
*/
async function refreshPermissionCache() {
const now = Date.now();
if (now - cacheLastUpdated < CACHE_TTL && permissionCache.size > 0) {
return;
}
try {
const rolePermissions = await db('role_permissions')
.join('roles', 'roles.id', 'role_permissions.role_id')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.select('roles.name as role_name', 'permissions.name as permission_name');
const newCache = new Map();
for (const rp of rolePermissions) {
if (!newCache.has(rp.role_name)) {
newCache.set(rp.role_name, new Set());
}
newCache.get(rp.role_name).add(rp.permission_name);
}
permissionCache = newCache;
cacheLastUpdated = now;
} catch (error) {
// Handle case where RBAC tables don't exist yet (upgrade scenario)
// Grant super_admin all permissions by default during upgrade window
if (error.message.includes('no such table') || error.message.includes('does not exist') || error.message.includes('relation')) {
logger.warn('RBAC tables not available yet - granting full access to authenticated users during upgrade');
const allPermissions = new Set([
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
'analytics.view', 'email.view', 'email.edit', 'email.send',
'branding.view', 'branding.edit', 'cms.view', 'cms.edit',
'settings.view', 'settings.edit', 'backup.view', 'backup.create', 'backup.restore', 'backup.delete',
'users.view', 'users.create', 'users.edit', 'users.delete',
'activity.view', 'activity.export'
]);
permissionCache.set('super_admin', allPermissions);
cacheLastUpdated = now;
} else {
logger.error('Failed to refresh permission cache', { error: error.message });
}
}
}
/**
* Check if a role has a specific permission
* @param {string} roleName - Role name to check
* @param {string} permissionName - Permission name to check
* @returns {Promise<boolean>}
*/
async function roleHasPermission(roleName, permissionName) {
await refreshPermissionCache();
const rolePerms = permissionCache.get(roleName);
return rolePerms ? rolePerms.has(permissionName) : false;
}
/**
* Check if user has any of the specified permissions
* @param {number} userId - User ID to check
* @param {string[]} permissions - Array of permission names
* @returns {Promise<boolean>}
*/
async function userHasAnyPermission(userId, permissions) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name')
.first();
if (!user) return false;
for (const perm of permissions) {
if (await roleHasPermission(user.role_name, perm)) {
return true;
}
}
return false;
}
/**
* Check if user has all specified permissions
* @param {number} userId - User ID to check
* @param {string[]} permissions - Array of permission names
* @returns {Promise<boolean>}
*/
async function userHasAllPermissions(userId, permissions) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name')
.first();
if (!user) return false;
for (const perm of permissions) {
if (!(await roleHasPermission(user.role_name, perm))) {
return false;
}
}
return true;
}
/**
* Middleware factory: require specific permission(s)
* @param {string|string[]} permissions - Permission name(s) required
* @param {object} options - { requireAll: boolean }
* @returns {Function} Express middleware
*/
function requirePermission(permissions, options = { requireAll: false }) {
const permArray = Array.isArray(permissions) ? permissions : [permissions];
return async (req, res, next) => {
try {
if (!req.admin || !req.admin.id) {
throw new ForbiddenError('Authentication required');
}
const hasPermission = options.requireAll
? await userHasAllPermissions(req.admin.id, permArray)
: await userHasAnyPermission(req.admin.id, permArray);
if (!hasPermission) {
logger.warn('Permission denied', {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: req.path,
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
}
next();
} catch (error) {
if (error instanceof ForbiddenError) {
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
}
next(error);
}
};
}
/**
* Middleware: require super_admin role
* @returns {Function} Express middleware
*/
function requireSuperAdmin() {
return async (req, res, next) => {
try {
if (!req.admin || !req.admin.id) {
throw new ForbiddenError('Authentication required');
}
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', req.admin.id)
.select('roles.name as role_name')
.first();
if (!user || user.role_name !== 'super_admin') {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: req.path,
method: req.method
});
throw new ForbiddenError('Super Admin access required');
}
next();
} catch (error) {
if (error instanceof ForbiddenError) {
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
}
next(error);
}
};
}
/**
* Get user's permissions for client
* @param {number} userId - User ID
* @returns {Promise<{role: object|null, permissions: string[]}>}
*/
async function getUserPermissions(userId) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name', 'roles.display_name as role_display_name')
.first();
if (!user) return { role: null, permissions: [] };
await refreshPermissionCache();
const permissions = permissionCache.get(user.role_name) || new Set();
return {
role: {
name: user.role_name,
displayName: user.role_display_name
},
permissions: Array.from(permissions)
};
}
/**
* Clear permission cache (useful for testing or when permissions change)
*/
function clearPermissionCache() {
permissionCache.clear();
cacheLastUpdated = 0;
}
module.exports = {
requirePermission,
requireSuperAdmin,
getUserPermissions,
userHasAnyPermission,
userHasAllPermissions,
roleHasPermission,
refreshPermissionCache,
clearPermissionCache
};
+36 -16
View File
@@ -2,14 +2,14 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
@@ -20,11 +20,24 @@ async function photoAuth(req, res, next) {
}
// First check for JWT token (from gallery access)
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '');
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
if (tokenFromRequest) {
const token = tokenFromRequest;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw issuerError;
}
}
// Check if it's a gallery token
if (decoded.type === 'gallery') {
@@ -66,29 +79,36 @@ async function photoAuth(req, res, next) {
// For both thumbnails and photos with admin token, allow access
return next();
}
} catch (err) {
// Token invalid, fall through to password check
console.error('JWT verification failed:', err.message);
} catch (err) {
// Token invalid, fall through to password check
logger.warn('JWT verification failed in photoAuth', { error: err.message });
}
}
// Check for password header (legacy support)
const password = req.headers['x-gallery-password'];
if (!password && !authHeader) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) {
if (!eventSlug && !password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
return next();
}
if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' });
}
if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
@@ -108,7 +128,7 @@ async function photoAuth(req, res, next) {
req.event = event;
next();
} catch (error) {
console.error('Photo auth error:', error);
logger.error('Photo auth error', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Authentication error' });
}
}
@@ -0,0 +1,409 @@
const { db } = require('../database/db');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
/**
* Enhanced secure image middleware with comprehensive protection
*/
class SecureImageMiddleware {
constructor() {
this.suspiciousIPs = new Set();
this.blockedFingerprints = new Set();
this.rateLimitViolations = new Map();
}
/**
* Main security middleware for image access
*/
secureImageAccess = async (req, res, next) => {
try {
const startTime = Date.now();
const clientIP = this.getClientIP(req);
const userAgent = req.get('User-Agent') || '';
const clientFingerprint = secureImageService.createClientFingerprint(req);
// Create client info object
req.clientInfo = {
ip: clientIP,
userAgent,
fingerprint: clientFingerprint,
timestamp: startTime
};
// Security checks
const securityCheck = await this.performSecurityChecks(req, res);
if (!securityCheck.passed) {
return res.status(securityCheck.status).json({
error: securityCheck.message
});
}
// Set security headers
this.setSecurityHeaders(res);
// Log successful security check
logger.info('Secure image access granted', {
ip: clientIP,
fingerprint: clientFingerprint,
photoId: req.params.photoId,
eventId: req.params.slug,
userAgent: userAgent.substring(0, 100)
});
next();
} catch (error) {
logger.error('Secure image middleware error', {
error: error.message,
stack: error.stack,
ip: req.ip,
path: req.path
});
res.status(500).json({
error: 'Security validation failed'
});
}
};
/**
* Perform comprehensive security checks
*/
async performSecurityChecks(req, res) {
const { clientInfo } = req;
const { photoId } = req.params;
// 1. Check if IP is blocked
if (this.suspiciousIPs.has(clientInfo.ip)) {
await this.logSecurityEvent('blocked_ip_access', req, { reason: 'IP on block list' });
return { passed: false, status: 403, message: 'Access denied' };
}
// 2. Check if fingerprint is blocked
if (this.blockedFingerprints.has(clientInfo.fingerprint)) {
await this.logSecurityEvent('blocked_fingerprint_access', req, { reason: 'Fingerprint blocked' });
return { passed: false, status: 403, message: 'Access denied' };
}
// 3. Rate limiting check
const rateLimit = await this.checkRateLimit(req);
if (!rateLimit.passed) {
await this.logSecurityEvent('rate_limit_exceeded', req, rateLimit);
return { passed: false, status: 429, message: 'Too many requests' };
}
// 4. Check for suspicious patterns
if (photoId) {
const suspiciousActivity = await secureImageService.detectSuspiciousActivity(
clientInfo.fingerprint,
photoId
);
if (suspiciousActivity) {
await this.logSecurityEvent('suspicious_activity', req, {
photoId,
reason: 'Multiple rapid accesses'
});
// Add to monitoring but don't block yet
this.flagSuspiciousActivity(clientInfo);
}
}
// 5. User-Agent validation
const userAgentValid = this.validateUserAgent(clientInfo.userAgent);
if (!userAgentValid.valid) {
await this.logSecurityEvent('invalid_user_agent', req, userAgentValid);
return { passed: false, status: 400, message: 'Invalid client' };
}
// 6. Check request headers for automation signs
const automationCheck = this.detectAutomation(req);
if (automationCheck.detected) {
await this.logSecurityEvent('automation_detected', req, automationCheck);
return { passed: false, status: 403, message: 'Automated access not allowed' };
}
return { passed: true };
}
/**
* Advanced rate limiting with multiple windows
*/
async checkRateLimit(req) {
const { clientInfo } = req;
const now = Date.now();
// Get rate limit settings from database
const settings = await this.getRateLimitSettings();
// Check different time windows
const windows = [
{ duration: 60000, limit: settings.perMinute || 30 }, // 1 minute
{ duration: 300000, limit: settings.per5Minutes || 100 }, // 5 minutes
{ duration: 3600000, limit: settings.perHour || 500 } // 1 hour
];
for (const window of windows) {
const allowed = secureImageService.checkRateLimit(
`${clientInfo.fingerprint}_${window.duration}`,
window.limit,
window.duration
);
if (!allowed) {
// Track violations
const violationKey = `${clientInfo.fingerprint}_violations`;
const violations = this.rateLimitViolations.get(violationKey) || 0;
this.rateLimitViolations.set(violationKey, violations + 1);
// Block after multiple violations
if (violations >= 5) {
this.blockedFingerprints.add(clientInfo.fingerprint);
logger.warn('Client fingerprint blocked due to repeated violations', {
fingerprint: clientInfo.fingerprint,
violations: violations + 1
});
}
return {
passed: false,
window: window.duration / 1000,
limit: window.limit,
violations: violations + 1
};
}
}
return { passed: true };
}
/**
* Validate User-Agent for legitimacy
*/
validateUserAgent(userAgent) {
if (!userAgent || userAgent.length < 10) {
return { valid: false, reason: 'Missing or too short User-Agent' };
}
// Check for common bot patterns
const botPatterns = [
/curl/i, /wget/i, /scrapy/i, /python/i, /requests/i,
/bot/i, /crawler/i, /spider/i, /scraper/i
];
for (const pattern of botPatterns) {
if (pattern.test(userAgent)) {
return { valid: false, reason: 'Bot User-Agent detected' };
}
}
// Check for valid browser patterns
const browserPatterns = [
/mozilla/i, /chrome/i, /safari/i, /firefox/i, /edge/i, /opera/i
];
const hasValidBrowser = browserPatterns.some(pattern => pattern.test(userAgent));
if (!hasValidBrowser) {
return { valid: false, reason: 'Invalid browser User-Agent' };
}
return { valid: true };
}
/**
* Detect automation and scripting attempts
*/
detectAutomation(req) {
const headers = req.headers;
const suspiciousHeaders = [];
// Check for automation indicators
if (!headers.accept) {
suspiciousHeaders.push('missing_accept_header');
}
if (!headers['accept-language']) {
suspiciousHeaders.push('missing_accept_language');
}
if (!headers['accept-encoding']) {
suspiciousHeaders.push('missing_accept_encoding');
}
// Check for scripting headers
if (headers['x-requested-with'] === 'XMLHttpRequest' && !headers.referer) {
suspiciousHeaders.push('ajax_without_referer');
}
// Check for headless browser indicators
if (headers['user-agent'] && headers['user-agent'].includes('HeadlessChrome')) {
suspiciousHeaders.push('headless_browser');
}
const detected = suspiciousHeaders.length >= 2;
return {
detected,
suspiciousHeaders,
score: suspiciousHeaders.length
};
}
/**
* Set comprehensive security headers
*/
setSecurityHeaders(res) {
res.set({
// Prevent caching
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
'Pragma': 'no-cache',
'Expires': '0',
// Security headers
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'none'; img-src 'self'",
// Custom security headers
'X-Protected-Content': 'true',
'X-Download-Policy': 'restricted',
// CORS restrictions
'Access-Control-Allow-Origin': process.env.FRONTEND_URL || '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Max-Age': '3600'
});
}
/**
* Get client IP address with proxy support
*/
getClientIP(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
req.ip;
}
/**
* Flag suspicious activity for monitoring
*/
flagSuspiciousActivity(clientInfo) {
const key = `suspicious_${clientInfo.fingerprint}`;
const existing = this.rateLimitViolations.get(key) || 0;
this.rateLimitViolations.set(key, existing + 1);
// Add to suspicious IPs after multiple flags
if (existing >= 3) {
this.suspiciousIPs.add(clientInfo.ip);
logger.warn('IP added to suspicious list', {
ip: clientInfo.ip,
fingerprint: clientInfo.fingerprint,
flags: existing + 1
});
}
}
/**
* Log security events
*/
async logSecurityEvent(eventType, req, details = {}) {
try {
const logData = {
event_type: eventType,
client_ip: req.clientInfo?.ip || req.ip,
client_fingerprint: req.clientInfo?.fingerprint,
user_agent: req.get('User-Agent')?.substring(0, 255),
request_path: req.path,
request_method: req.method,
details: JSON.stringify(details),
timestamp: new Date().toISOString()
};
logger.warn(`Security event: ${eventType}`, logData);
// Store in database if needed
if (process.env.LOG_SECURITY_EVENTS === 'true') {
await db('security_logs').insert(logData).catch(console.error);
}
} catch (error) {
console.error('Error logging security event:', error);
}
}
/**
* Get rate limit settings from database
*/
async getRateLimitSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'max_image_requests_per_minute',
'max_image_requests_per_5_minutes',
'max_image_requests_per_hour'
])
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
const key = setting.setting_key.replace('max_image_requests_per_', '');
config[key === 'minute' ? 'perMinute' : key === '5_minutes' ? 'per5Minutes' : 'perHour'] =
JSON.parse(setting.setting_value);
});
return {
perMinute: config.perMinute || 30,
per5Minutes: config.per5Minutes || 100,
perHour: config.perHour || 500
};
} catch (error) {
console.error('Error getting rate limit settings:', error);
return { perMinute: 30, per5Minutes: 100, perHour: 500 };
}
}
/**
* Clean up old security data
*/
cleanup() {
const now = Date.now();
// Clear old rate limit violations (older than 1 hour)
for (const [key, timestamp] of this.rateLimitViolations.entries()) {
if (typeof timestamp === 'number' && now - timestamp > 3600000) {
this.rateLimitViolations.delete(key);
}
}
// Clean up the secure image service
secureImageService.cleanup();
}
/**
* Get security status
*/
getSecurityStatus() {
return {
suspiciousIPsCount: this.suspiciousIPs.size,
blockedFingerprintsCount: this.blockedFingerprints.size,
activeViolations: this.rateLimitViolations.size,
timestamp: new Date().toISOString()
};
}
}
// Create singleton instance
const secureImageMiddleware = new SecureImageMiddleware();
// Setup cleanup interval
setInterval(() => {
secureImageMiddleware.cleanup();
}, 300000); // Every 5 minutes
module.exports = secureImageMiddleware;
+3 -6
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
// In-memory session tracking (in production, use Redis)
const sessions = new Map();
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
async function sessionTimeoutMiddleware(req, res, next) {
// Skip for non-authenticated routes
if (!req.headers.authorization) {
return next();
}
const token = req.headers.authorization.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
return next();
}
@@ -150,4 +147,4 @@ module.exports = {
sessionTimeoutMiddleware,
endSession,
getActiveSessions
};
};
@@ -0,0 +1,99 @@
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, updateResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereNot: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
update: jest.fn().mockResolvedValue(updateResult ?? 1),
first: jest.fn().mockResolvedValue(firstResult),
};
return chain;
};
jest.mock('../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../middleware/auth', () => ({
adminAuth: (_req, _res, next) => {
_req.admin = { id: 1, username: 'admin' };
next();
},
}));
const { db, logActivity } = require('../../database/db');
const adminAuthRouter = require('../adminAuth');
describe('adminAuth profile updates', () => {
const app = express();
app.use(express.json());
app.use('/auth/admin', adminAuthRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('updates the admin profile', async () => {
const updatedUser = {
id: 1,
username: 'newadmin',
email: 'newadmin@example.com',
must_change_password: false,
};
db.__setImplementations(
buildChain({ firstResult: null }), // email check
buildChain({ firstResult: null }), // username check
buildChain({ updateResult: 1 }), // update
buildChain({ firstResult: updatedUser }), // fetch updated user
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: updatedUser.username, email: updatedUser.email })
.expect(200);
expect(response.body).toEqual({ user: updatedUser });
expect(logActivity).toHaveBeenCalledWith(
'admin_profile_updated',
{ admin_id: 1, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: 1, name: updatedUser.username }
);
});
it('rejects email conflicts', async () => {
db.__setImplementations(
buildChain({ firstResult: { id: 2 } })
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: 'newadmin', email: 'taken@example.com' })
.expect(409);
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
});
it('validates input', async () => {
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: '', email: 'not-an-email' })
.expect(400);
expect(response.body.errors).toBeDefined();
});
});
@@ -0,0 +1,67 @@
const request = require('supertest');
const express = require('express');
jest.mock('../../database/db', () => {
const deleteMock = jest.fn().mockResolvedValue(5);
const chain = {
select: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
whereNull: jest.fn().mockReturnThis(),
whereNotNull: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: deleteMock,
count: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue({ count: 0 }),
};
const dbMock = jest.fn(() => chain);
dbMock.raw = jest.fn();
dbMock.__chain = chain;
dbMock.__deleteMock = deleteMock;
return { db: dbMock };
});
jest.mock('../../middleware/auth', () => ({
adminAuth: (_req, _res, next) => next(),
}));
const { db } = require('../../database/db');
const notificationsRouter = require('../adminNotifications');
describe('adminNotifications routes', () => {
const app = express();
app.use(express.json());
app.use('/admin/notifications', notificationsRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('clears all notifications', async () => {
db.__deleteMock.mockResolvedValueOnce(8);
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(200);
expect(db).toHaveBeenCalledWith('activity_logs');
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
expect(response.body).toEqual({
message: 'All notifications cleared',
deletedCount: 8,
});
});
it('handles database errors when clearing notifications', async () => {
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(500);
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Accept Invitation Routes (Public)
* Handles invitation token validation and account creation
*/
const express = require('express');
const { body, param } = require('express-validator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const userManagementService = require('../services/userManagementService');
const router = express.Router();
/**
* GET /:token
* Validate invitation token
* Public endpoint - no auth required
*/
router.get('/:token', [
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token')
], handleAsync(async (req, res) => {
validateRequest(req);
const invitation = await userManagementService.validateInvitationToken(req.params.token);
if (!invitation) {
return res.status(404).json({ error: 'Invalid or expired invitation' });
}
res.json({
valid: true,
email: invitation.email,
role: invitation.role_name,
expiresAt: invitation.expires_at
});
}));
/**
* POST /:token
* Accept invitation and create account
* Public endpoint - no auth required
*/
router.post('/:token', [
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token'),
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be 3-50 characters')
.matches(/^[a-zA-Z0-9_-]+$/)
.withMessage('Username can only contain letters, numbers, underscores, and hyphens'),
body('password')
.isLength({ min: 12 })
.withMessage('Password must be at least 12 characters')
.custom((value) => {
const validation = validatePasswordStrength(value);
if (!validation.isValid) {
throw new Error(validation.messages.join(', '));
}
return true;
})
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await userManagementService.acceptInvitation({
token: req.params.token,
username: req.body.username,
password: req.body.password
});
successResponse(res, {
message: 'Account created successfully. You can now log in.',
email: result.email
}, 201);
}));
module.exports = router;
+7 -6
View File
@@ -3,13 +3,14 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const router = express.Router();
// Get all archived events
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get single archive details
router.get('/:id', adminAuth, async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
});
// Restore archive
router.post('/:id/restore', adminAuth, async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
});
// Download archive
router.get('/:id/download', adminAuth, async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
});
// Delete archive permanently
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)

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