├── video-test-data ├── bear-320x240.mp4 └── bear-320x240.webm ├── test-assets ├── img1.png └── img2.png ├── docs ├── picpeak-logo.png ├── screenshot-gallery.png ├── screenshots-events.png ├── screenshot-analytics.png ├── screenshot-dashboard.png └── nginx-fix.md ├── frontend ├── src │ ├── vite-env.d.ts │ ├── components │ │ ├── admin │ │ │ ├── BackupHistory.d.ts │ │ │ ├── RestoreWizard.d.ts │ │ │ ├── BackupDashboard.d.ts │ │ │ ├── BackupConfiguration.d.ts │ │ │ ├── AdminAuthWrapper.tsx │ │ │ ├── VersionInfo.tsx │ │ │ ├── MaintenanceBanner.tsx │ │ │ ├── PhotoUploadModal.tsx │ │ │ ├── index.ts │ │ │ ├── AdminAuthenticatedVideo.tsx │ │ │ ├── WelcomeMessageEditor.tsx │ │ │ ├── AdminAuthenticatedImage.tsx │ │ │ └── __tests__ │ │ │ │ └── ThemeCustomizerEnhanced.test.tsx │ │ ├── common │ │ │ ├── SkipLink.tsx │ │ │ ├── index.ts │ │ │ ├── DynamicFavicon.tsx │ │ │ ├── ReCaptcha.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Button.tsx │ │ │ ├── Input.tsx │ │ │ └── Card.tsx │ │ ├── gallery │ │ │ ├── layouts │ │ │ │ ├── index.ts │ │ │ │ └── BaseGalleryLayout.tsx │ │ │ ├── index.ts │ │ │ ├── ExpirationBanner.tsx │ │ │ └── DownloadProgress.tsx │ │ └── GlobalThemeProvider.tsx │ ├── pages │ │ ├── admin │ │ │ ├── BackupManagement.d.ts │ │ │ └── index.ts │ │ └── MaintenancePage.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useOnClickOutside.ts │ │ ├── useWatermarkSettings.ts │ │ ├── useSessionTimeout.ts │ │ ├── useFocusTrap.ts │ │ ├── useLocalizedTimeAgo.ts │ │ ├── useGallery.ts │ │ └── useLocalizedDate.ts │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── contexts │ │ ├── index.ts │ │ └── MaintenanceContext.tsx │ ├── services │ │ ├── index.ts │ │ ├── externalMedia.service.ts │ │ ├── toast.service.ts │ │ ├── cms.service.ts │ │ ├── publicSettings.service.ts │ │ ├── categories.service.ts │ │ ├── email.service.ts │ │ └── auth.service.ts │ ├── utils │ │ ├── photoUrl.ts │ │ ├── accessControl.ts │ │ ├── cleanupGalleryAuth.ts │ │ └── __tests__ │ │ │ └── url.test.ts │ ├── i18n │ │ └── config.ts │ └── styles │ │ └── prose-overrides.css ├── public │ ├── favicon-32x32.png │ ├── picpeak-logo.png │ ├── picpeak-logo-transparent.png │ ├── picpeak-kamera-transparent.png │ └── vite.svg ├── postcss.config.js ├── tsconfig.json ├── .dockerignore ├── vitest.setup.ts ├── .gitignore ├── index.html ├── audit-frontend.json ├── Dockerfile.dev ├── tsconfig.node.json ├── .env.production.example ├── .env.example ├── tsconfig.app.json ├── nginx.dev.conf ├── vite.config.ts ├── eslint.config.js ├── Dockerfile.prod ├── Dockerfile └── tailwind.config.js ├── backend ├── data │ └── photo_sharing.db ├── .dockerignore ├── src │ ├── config │ │ ├── storage.js │ │ └── validateEnv.js │ ├── utils │ │ ├── cssSanitizer.js │ │ ├── formatters.js │ │ ├── requestIp.js │ │ ├── shareLinkUtils.js │ │ ├── filenameSanitizer.js │ │ └── cleanupTempUploads.js │ ├── routes │ │ ├── publicCMS.js │ │ ├── admin.js │ │ ├── __tests__ │ │ │ └── adminNotifications.test.js │ │ └── adminCMS.js │ ├── services │ │ ├── recaptcha.js │ │ ├── emailService.js │ │ ├── workerManager.js │ │ ├── uploadSettings.js │ │ └── photoResolver.js │ └── middleware │ │ └── secureStatic.js ├── jest.setup.js ├── jest.config.js ├── audit-backend.json ├── migrations │ ├── legacy │ │ ├── 007_add_read_at_to_activity_logs.js │ │ ├── 012_add_hero_photo_id.js │ │ ├── 018_add_created_at_to_email_queue.js │ │ ├── 023_ensure_postgres_compatibility.js │ │ ├── 015_add_login_attempts_table.js │ │ ├── 006_add_photo_counter_to_categories.js │ │ ├── 014_add_host_name_to_events_duplicate.js │ │ ├── 024_fix_boolean_compatibility.js │ │ ├── 025_fix_email_queue_updated_at.js │ │ ├── 008_add_language_support_to_email_templates.js │ │ ├── 019_fix_email_templates_columns.js │ │ ├── 016_add_auth_security_columns.js │ │ ├── 027_add_language_preferences.js │ │ ├── 027_add_rate_limit_settings_duplicate.js │ │ └── 011_add_user_upload_settings.js │ ├── core │ │ ├── 044_add_event_password_toggle.js │ │ ├── 047_add_tls_reject_unauthorized.js │ │ ├── 040_add_thumbnail_settings.js │ │ ├── 043_add_public_site_settings.js │ │ ├── 046_add_customer_contact_fields.js │ │ ├── 045_add_max_files_per_upload_setting.js │ │ ├── 042_backfill_event_upload_columns.js │ │ ├── 041_add_external_media.js │ │ └── 037_add_download_controls.js │ └── README.md ├── ecosystem.config.js ├── .eslintrc.js ├── Dockerfile.dev ├── scripts │ ├── set-admin-password.js │ ├── show-admin-credentials.js │ └── create-admin.js ├── init-production.sh ├── .env.example ├── Dockerfile ├── package.json └── wait-for-db.sh ├── .dockerignore ├── package.json ├── playwright.config.ts ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.md │ ├── documentation.md │ ├── security_vulnerability.md │ ├── feature_request.md │ └── bug_report.md └── pull_request_template.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── scripts ├── generate-jwt-secret.sh ├── backup.sh └── install.sh ├── docker-compose.override.yml.example ├── nginx └── nginx.conf ├── .gitignore ├── tests └── e2e │ ├── admin-create-event-ui.spec.ts │ └── admin-account-settings.spec.ts └── .env.example /video-test-data/bear-320x240.mp4: -------------------------------------------------------------------------------- 1 | 404: Not Found -------------------------------------------------------------------------------- /test-assets/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/test-assets/img1.png -------------------------------------------------------------------------------- /test-assets/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/test-assets/img2.png -------------------------------------------------------------------------------- /docs/picpeak-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/docs/picpeak-logo.png -------------------------------------------------------------------------------- /docs/screenshot-gallery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/docs/screenshot-gallery.png -------------------------------------------------------------------------------- /docs/screenshots-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/docs/screenshots-events.png -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /backend/data/photo_sharing.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/backend/data/photo_sharing.db -------------------------------------------------------------------------------- /docs/screenshot-analytics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/docs/screenshot-analytics.png -------------------------------------------------------------------------------- /docs/screenshot-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/docs/screenshot-dashboard.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/picpeak-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/frontend/public/picpeak-logo.png -------------------------------------------------------------------------------- /video-test-data/bear-320x240.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/video-test-data/bear-320x240.webm -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/picpeak-logo-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/frontend/public/picpeak-logo-transparent.png -------------------------------------------------------------------------------- /frontend/public/picpeak-kamera-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-luap/picpeak/HEAD/frontend/public/picpeak-kamera-transparent.png -------------------------------------------------------------------------------- /frontend/src/components/admin/BackupHistory.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react'; 2 | 3 | export const BackupHistory: ComponentType; 4 | -------------------------------------------------------------------------------- /frontend/src/components/admin/RestoreWizard.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react'; 2 | 3 | export const RestoreWizard: ComponentType; 4 | -------------------------------------------------------------------------------- /frontend/src/components/admin/BackupDashboard.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react'; 2 | 3 | export const BackupDashboard: ComponentType; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/admin/BackupManagement.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react'; 2 | 3 | export const BackupManagement: ComponentType; 4 | -------------------------------------------------------------------------------- /frontend/src/components/admin/BackupConfiguration.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from 'react'; 2 | 3 | export const BackupConfiguration: ComponentType; 4 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useSessionTimeout'; 2 | export * from './useOnClickOutside'; 3 | export * from './useLocalizedDate'; 4 | export * from './useLocalizedTimeAgo'; -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } -------------------------------------------------------------------------------- /frontend/src/pages/MaintenancePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MaintenanceMode } from '../components/MaintenanceMode'; 3 | 4 | export const MaintenancePage: React.FC = () => { 5 | return ; 6 | }; -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .env 4 | storage/events/active/* 5 | storage/events/archived/* 6 | storage/thumbnails/* 7 | data/*.db 8 | logs/* 9 | coverage 10 | .git 11 | .gitignore 12 | README.md 13 | .eslintrc.js 14 | jest.config.js 15 | -------------------------------------------------------------------------------- /backend/src/config/storage.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // Get storage path from environment or default 4 | const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 5 | 6 | module.exports = { 7 | getStoragePath 8 | }; -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .git 4 | .gitignore 5 | .env* 6 | .DS_Store 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | .vscode 12 | .idea 13 | *.swp 14 | *.swo 15 | README.md 16 | .eslintcache 17 | coverage 18 | .nyc_output -------------------------------------------------------------------------------- /frontend/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, vi } from 'vitest'; 2 | import * as matchers from '@testing-library/jest-dom/matchers'; 3 | 4 | expect.extend(matchers); 5 | 6 | // Provide Jest-compatible globals for existing tests that rely on jest.fn 7 | (globalThis as any).jest = vi; 8 | -------------------------------------------------------------------------------- /backend/jest.setup.js: -------------------------------------------------------------------------------- 1 | beforeAll(() => { 2 | process.env.NODE_ENV = 'test'; 3 | process.env.JWT_SECRET = 'test-secret'; 4 | if (!process.env.SKIP_S3_TESTS) { 5 | process.env.SKIP_S3_TESTS = 'true'; 6 | } 7 | if (!process.env.STORAGE_PATH) { 8 | process.env.STORAGE_PATH = '/storage'; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | *.md 4 | .env 5 | .env.* 6 | docker-compose*.yml 7 | .DS_Store 8 | node_modules 9 | npm-debug.log 10 | coverage 11 | .nyc_output 12 | .vscode 13 | .idea 14 | *.swp 15 | *.swo 16 | storage/events/active/* 17 | storage/events/archived/* 18 | storage/thumbnails/* 19 | data/*.db 20 | logs/* 21 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | coverageDirectory: 'coverage', 4 | collectCoverageFrom: [ 5 | 'src/**/*.js', 6 | '!src/**/*.test.js' 7 | ], 8 | testMatch: [ 9 | '**/__tests__/**/*.test.js' 10 | ], 11 | setupFilesAfterEnv: ['/jest.setup.js'] 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import './styles/prose-overrides.css' 5 | import './i18n/config' 6 | import App from './App.tsx' 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /frontend/src/components/admin/AdminAuthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Outlet } from 'react-router-dom'; 3 | import { AdminAuthProvider } from '../../contexts'; 4 | 5 | export const AdminAuthWrapper: React.FC = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | AdminAuthWrapper.displayName = 'AdminAuthWrapper'; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test:e2e": "playwright test" 4 | }, 5 | "dependencies": { 6 | "better-sqlite3": "^12.2.0", 7 | "canvas": "^3.2.0", 8 | "node-fetch": "^2.7.0" 9 | }, 10 | "devDependencies": { 11 | "puppeteer": "^24.17.0", 12 | "@playwright/test": "^1.48.2" 13 | }, 14 | "overrides": { 15 | "prebuild-install": { 16 | "tar-fs": "2.1.4" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/common/SkipLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SkipLink: React.FC = () => { 4 | return ( 5 | 9 | Skip to main content 10 | 11 | ); 12 | }; -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Environment files 27 | .env 28 | .env.local 29 | .env.*.local 30 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PicPeak - Photo Sharing Platform 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /backend/audit-backend.json: -------------------------------------------------------------------------------- 1 | { 2 | "auditReportVersion": 2, 3 | "vulnerabilities": {}, 4 | "metadata": { 5 | "vulnerabilities": { 6 | "info": 0, 7 | "low": 0, 8 | "moderate": 0, 9 | "high": 0, 10 | "critical": 0, 11 | "total": 0 12 | }, 13 | "dependencies": { 14 | "prod": 329, 15 | "dev": 307, 16 | "optional": 54, 17 | "peer": 1, 18 | "peerOptional": 0, 19 | "total": 690 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/audit-frontend.json: -------------------------------------------------------------------------------- 1 | { 2 | "auditReportVersion": 2, 3 | "vulnerabilities": {}, 4 | "metadata": { 5 | "vulnerabilities": { 6 | "info": 0, 7 | "low": 0, 8 | "moderate": 0, 9 | "high": 0, 10 | "critical": 0, 11 | "total": 0 12 | }, 13 | "dependencies": { 14 | "prod": 136, 15 | "dev": 298, 16 | "optional": 47, 17 | "peer": 0, 18 | "peerOptional": 0, 19 | "total": 433 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/gallery/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { GridGalleryLayout } from './GridGalleryLayout'; 2 | export { MasonryGalleryLayout } from './MasonryGalleryLayout'; 3 | export { CarouselGalleryLayout } from './CarouselGalleryLayout'; 4 | export { TimelineGalleryLayout } from './TimelineGalleryLayout'; 5 | export { HeroGalleryLayout } from './HeroGalleryLayout'; 6 | export { MosaicGalleryLayout } from './MosaicGalleryLayout'; 7 | export type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; -------------------------------------------------------------------------------- /frontend/src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export { GalleryAuthProvider, useGalleryAuth } from './GalleryAuthContext'; 2 | export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext'; 3 | export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext'; 4 | export type { ThemeConfig, EventTheme } from './ThemeContext'; 5 | export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility 6 | export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext'; -------------------------------------------------------------------------------- /backend/migrations/legacy/007_add_read_at_to_activity_logs.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add read_at column to activity_logs table 3 | const hasReadAt = await knex.schema.hasColumn('activity_logs', 'read_at'); 4 | if (!hasReadAt) { 5 | await knex.schema.table('activity_logs', (table) => { 6 | table.datetime('read_at').nullable(); 7 | }); 8 | } 9 | }; 10 | 11 | exports.down = async function(knex) { 12 | await knex.schema.table('activity_logs', (table) => { 13 | table.dropColumn('read_at'); 14 | }); 15 | }; -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | testDir: 'tests/e2e', 5 | timeout: 60_000, 6 | retries: 0, 7 | use: { 8 | baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', 9 | headless: true, 10 | viewport: { width: 1280, height: 800 }, 11 | ignoreHTTPSErrors: true, 12 | }, 13 | projects: [ 14 | { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, 15 | { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 📚 Documentation 4 | url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md 5 | about: Please read the documentation before opening an issue 6 | - name: 💬 Discussions 7 | url: https://github.com/the-luap/picpeak/discussions 8 | about: Ask questions and discuss with the community 9 | - name: 🔒 Security Issues 10 | url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md 11 | about: Please review our security policy for reporting vulnerabilities -------------------------------------------------------------------------------- /frontend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Dockerfile.dev - Development configuration for frontend 2 | FROM node:20-alpine 3 | 4 | WORKDIR /app 5 | 6 | # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) 7 | RUN apk upgrade --no-cache 8 | 9 | # Copy package files 10 | COPY package*.json ./ 11 | 12 | # Install dependencies 13 | RUN npm ci --legacy-peer-deps 14 | 15 | # Copy source code 16 | COPY . . 17 | 18 | # Expose the development server port 19 | EXPOSE 3005 20 | 21 | # Start development server 22 | CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3005"] -------------------------------------------------------------------------------- /backend/migrations/legacy/012_add_hero_photo_id.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add hero_photo_id to events table 3 | const hasColumn = await knex.schema.hasColumn('events', 'hero_photo_id'); 4 | if (!hasColumn) { 5 | await knex.schema.alterTable('events', function(table) { 6 | table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); 7 | }); 8 | } 9 | }; 10 | 11 | exports.down = async function(knex) { 12 | await knex.schema.alterTable('events', function(table) { 13 | table.dropColumn('hero_photo_id'); 14 | }); 15 | }; -------------------------------------------------------------------------------- /backend/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [{ 3 | name: 'picpeak', 4 | script: './server.js', 5 | instances: 'max', 6 | exec_mode: 'cluster', 7 | env: { 8 | NODE_ENV: 'production', 9 | PORT: 3000 10 | }, 11 | error_file: './logs/pm2-error.log', 12 | out_file: './logs/pm2-out.log', 13 | log_date_format: 'YYYY-MM-DD HH:mm:ss', 14 | max_memory_restart: '1G', 15 | watch: false, 16 | ignore_watch: ['node_modules', 'logs', 'storage'], 17 | wait_ready: true, 18 | listen_timeout: 3000, 19 | kill_timeout: 5000 20 | }] 21 | }; 22 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | node: true, 6 | jest: true 7 | }, 8 | extends: [ 9 | 'eslint:recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest', 13 | sourceType: 'module' 14 | }, 15 | rules: { 16 | 'indent': ['error', 2], 17 | 'linebreak-style': ['error', 'unix'], 18 | 'quotes': ['error', 'single'], 19 | 'semi': ['error', 'always'], 20 | 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], 21 | 'no-console': ['warn', { allow: ['warn', 'error'] }] 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { authService } from './auth.service'; 2 | export { galleryService } from './gallery.service'; 3 | export { eventsService } from './events.service'; 4 | export { adminService } from './admin.service'; 5 | export { analyticsService } from './analytics.service'; 6 | export { archiveService } from './archive.service'; 7 | export { emailService } from './email.service'; 8 | export { settingsService } from './settings.service'; 9 | export { cmsService } from './cms.service'; 10 | export { notificationsService } from './notifications.service'; 11 | export { feedbackService } from './feedback.service'; -------------------------------------------------------------------------------- /frontend/src/components/gallery/index.ts: -------------------------------------------------------------------------------- 1 | export { GalleryView } from './GalleryView'; 2 | export { PhotoGrid } from './PhotoGrid'; 3 | export { PhotoLightbox } from './PhotoLightbox'; 4 | export { ExpirationBanner } from './ExpirationBanner'; 5 | export { CountdownTimer } from './CountdownTimer'; 6 | export { GalleryLayout } from './GalleryLayout'; 7 | export { PhotoFilterBar } from './PhotoFilterBar'; 8 | export { UserPhotoUpload } from './UserPhotoUpload'; 9 | export { PhotoFeedback } from './PhotoFeedback'; 10 | export { PhotoRating } from './PhotoRating'; 11 | export { PhotoLikes } from './PhotoLikes'; 12 | export { PhotoComments } from './PhotoComments'; 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about PicPeak 4 | title: '[QUESTION] ' 5 | labels: 'question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Question** 11 | What would you like to know about PicPeak? 12 | 13 | **Context** 14 | Please provide context to help us answer your question better: 15 | - What are you trying to achieve? 16 | - What have you already tried? 17 | - Which documentation have you consulted? 18 | 19 | **Environment** 20 | If relevant to your question: 21 | - PicPeak Version: 22 | - Deployment Method: 23 | - Operating System: 24 | 25 | **Related Issues or Discussions** 26 | Link to any related issues, discussions, or documentation. -------------------------------------------------------------------------------- /backend/migrations/core/044_add_event_password_toggle.js: -------------------------------------------------------------------------------- 1 | exports.up = async function (knex) { 2 | const hasColumn = await knex.schema.hasColumn('events', 'require_password'); 3 | if (!hasColumn) { 4 | await knex.schema.table('events', (table) => { 5 | table.boolean('require_password').notNullable().defaultTo(true); 6 | }); 7 | await knex('events').update({ require_password: true }); 8 | } 9 | }; 10 | 11 | exports.down = async function (knex) { 12 | const hasColumn = await knex.schema.hasColumn('events', 'require_password'); 13 | if (hasColumn) { 14 | await knex.schema.table('events', (table) => { 15 | table.dropColumn('require_password'); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/admin/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminLoginPage } from './AdminLoginPage'; 2 | export { AdminDashboard } from './AdminDashboard'; 3 | export { EventsListPage } from './EventsListPage'; 4 | export { CreateEventPageEnhanced } from './CreateEventPageEnhanced'; 5 | export { EventDetailsPage } from './EventDetailsPage'; 6 | export { EmailConfigPage } from './EmailConfigPage'; 7 | export { ArchivesPage } from './ArchivesPage'; 8 | export { AnalyticsPage } from './AnalyticsPage'; 9 | export { BrandingPage } from './BrandingPage'; 10 | export { SettingsPage } from './SettingsPage'; 11 | export { CMSPage } from './CMSPage'; 12 | export { BackupManagement } from './BackupManagement'; 13 | export { EventFeedbackPage } from './EventFeedbackPage'; -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /backend/migrations/core/047_add_tls_reject_unauthorized.js: -------------------------------------------------------------------------------- 1 | const { addColumnIfNotExists } = require('../helpers'); 2 | 3 | exports.up = async function up(knex) { 4 | // Add tls_reject_unauthorized column to email_configs table 5 | // Default is true (validate certificates), false means ignore SSL/TLS certificate errors 6 | await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => { 7 | table.boolean('tls_reject_unauthorized').defaultTo(true); 8 | }); 9 | }; 10 | 11 | exports.down = async function down(knex) { 12 | const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized'); 13 | if (hasColumn) { 14 | await knex.schema.alterTable('email_configs', (table) => { 15 | table.dropColumn('tls_reject_unauthorized'); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /backend/migrations/legacy/018_add_created_at_to_email_queue.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Check if created_at column already exists 3 | const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at'); 4 | 5 | if (!hasCreatedAt) { 6 | await knex.schema.table('email_queue', (table) => { 7 | table.datetime('created_at').defaultTo(knex.fn.now()); 8 | }); 9 | 10 | // Update existing rows to have a created_at value based on scheduled_at 11 | await knex('email_queue') 12 | .whereNull('created_at') 13 | .update({ 14 | created_at: knex.ref('scheduled_at') 15 | }); 16 | } 17 | }; 18 | 19 | exports.down = async function(knex) { 20 | await knex.schema.table('email_queue', (table) => { 21 | table.dropColumn('created_at'); 22 | }); 23 | }; -------------------------------------------------------------------------------- /backend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) 6 | RUN apk upgrade --no-cache 7 | 8 | # Install dumb-init for proper signal handling 9 | RUN apk add --no-cache dumb-init 10 | 11 | # Copy package files 12 | COPY package*.json ./ 13 | 14 | # Install all dependencies (including dev) 15 | RUN npm install 16 | 17 | # Copy application files 18 | COPY . . 19 | 20 | # Create necessary directories 21 | RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs 22 | 23 | # Create non-root user 24 | RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 25 | RUN chown -R nodejs:nodejs /app 26 | 27 | USER nodejs 28 | 29 | EXPOSE 3000 30 | 31 | ENTRYPOINT ["dumb-init", "--"] 32 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /frontend/src/utils/photoUrl.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from '../types'; 2 | 3 | interface PhotoUrlOptions { 4 | slug: string; 5 | photo: Photo; 6 | watermarkEnabled?: boolean; 7 | token?: string; 8 | } 9 | 10 | /** 11 | * Get the appropriate URL for a photo, using watermarked endpoint if enabled 12 | */ 13 | export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string { 14 | if (watermarkEnabled && token) { 15 | // Use the watermarked photo endpoint 16 | return `/gallery/${slug}/photo/${photo.id}`; 17 | } 18 | 19 | // Use the static photo URL 20 | return photo.url; 21 | } 22 | 23 | /** 24 | * Get the download URL for a photo 25 | */ 26 | export function getPhotoDownloadUrl(slug: string, photoId: number): string { 27 | return `/gallery/${slug}/download/${photoId}`; 28 | } -------------------------------------------------------------------------------- /backend/migrations/legacy/023_ensure_postgres_compatibility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensure PostgreSQL compatibility for all insert operations 3 | * This migration doesn't change the schema but ensures all tables 4 | * are compatible with .returning() syntax 5 | */ 6 | 7 | exports.up = async function(knex) { 8 | // This migration is informational only 9 | // All insert operations should use .returning('id') going forward 10 | 11 | console.log('PostgreSQL compatibility check:'); 12 | console.log('- All INSERT operations should use .returning("id")'); 13 | console.log('- All date operations should use ISO strings'); 14 | console.log('- Boolean values are handled automatically by Knex'); 15 | 16 | return Promise.resolve(); 17 | }; 18 | 19 | exports.down = async function(knex) { 20 | // No rollback needed 21 | return Promise.resolve(); 22 | }; -------------------------------------------------------------------------------- /frontend/src/hooks/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, type RefObject } from 'react'; 2 | 3 | export function useOnClickOutside( 4 | ref: RefObject, 5 | handler: () => void 6 | ) { 7 | useEffect(() => { 8 | const listener = (event: MouseEvent | TouchEvent) => { 9 | // Do nothing if clicking ref's element or descendent elements 10 | if (!ref.current || ref.current.contains(event.target as Node)) { 11 | return; 12 | } 13 | handler(); 14 | }; 15 | 16 | document.addEventListener('mousedown', listener); 17 | document.addEventListener('touchstart', listener); 18 | 19 | return () => { 20 | document.removeEventListener('mousedown', listener); 21 | document.removeEventListener('touchstart', listener); 22 | }; 23 | }, [ref, handler]); 24 | } -------------------------------------------------------------------------------- /backend/src/utils/cssSanitizer.js: -------------------------------------------------------------------------------- 1 | function sanitizeCss(css) { 2 | if (!css || typeof css !== 'string') { 3 | return ''; 4 | } 5 | 6 | let sanitized = css; 7 | 8 | const disallowedPatterns = [ 9 | /@import[^;]+;?/gi, 10 | /@charset[^;]+;?/gi, 11 | /expression\s*\([^)]*\)/gi, 12 | /url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi, 13 | /url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi 14 | ]; 15 | 16 | disallowedPatterns.forEach((pattern) => { 17 | sanitized = sanitized.replace(pattern, ''); 18 | }); 19 | 20 | sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); 21 | 22 | const MAX_LENGTH = 100 * 1024; 23 | if (sanitized.length > MAX_LENGTH) { 24 | sanitized = sanitized.slice(0, MAX_LENGTH); 25 | } 26 | 27 | return sanitized.trim(); 28 | } 29 | 30 | module.exports = { 31 | sanitizeCss, 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/.env.production.example: -------------------------------------------------------------------------------- 1 | # Production Environment Configuration 2 | # When running behind a reverse proxy like Traefik, use relative URLs 3 | 4 | # Backend API URL 5 | # For production behind reverse proxy, use relative URL: 6 | VITE_API_URL=/api 7 | 8 | # For development or if frontend/backend are on different domains: 9 | # VITE_API_URL=https://api.yourdomain.com 10 | 11 | # Umami Analytics Configuration (OPTIONAL - Fallback only) 12 | # NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics 13 | # These environment variables serve as fallbacks when backend settings are not available 14 | # 15 | # Real-world example values: 16 | # VITE_UMAMI_URL=https://analytics.picpeak.com 17 | # VITE_UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab 18 | # VITE_UMAMI_SHARE_URL=https://analytics.picpeak.com/share/Ab3Cd5Fg/picpeak-gallery -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | # Backend API URL 2 | # For local development with Docker: 3 | VITE_API_URL=http://localhost:3001/api 4 | 5 | # For local development without Docker: 6 | # VITE_API_URL=http://localhost:3001 7 | 8 | # For production behind reverse proxy (Traefik, nginx, etc): 9 | # VITE_API_URL=/api 10 | 11 | # Umami Analytics Configuration (OPTIONAL - Fallback only) 12 | # NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics 13 | # These environment variables serve as fallbacks when backend settings are not available 14 | # Useful for: development environments, initial setup, or when backend is unavailable 15 | # 16 | # Example values: 17 | # VITE_UMAMI_URL=https://analytics.example.com 18 | # VITE_UMAMI_WEBSITE_ID=abc123def-4567-89ab-cdef-0123456789ab 19 | # VITE_UMAMI_SHARE_URL=https://analytics.example.com/share/xyz789/wedding-photos -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "verbatimModuleSyntax": false, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "erasableSyntaxOnly": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedSideEffectImports": true 26 | }, 27 | "include": ["src"], 28 | "exclude": [ 29 | "src/**/__tests__/**" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # PicPeak Community Guidelines 2 | 3 | ## Our Commitment 4 | 5 | We are committed to providing a welcoming and inspiring community for all photographers and developers. 6 | 7 | ## Expected Behavior 8 | 9 | * Be respectful and considerate 10 | * Welcome newcomers and help them get started 11 | * Focus on what is best for the community 12 | * Show empathy towards other community members 13 | 14 | ## Unacceptable Behavior 15 | 16 | * Trolling or insulting comments 17 | * Personal attacks 18 | * Public or private harassment 19 | * Publishing others' private information 20 | 21 | ## Enforcement 22 | 23 | Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly. 24 | 25 | ## Attribution 26 | 27 | This Code of Conduct is adapted from contributor-covenant.org, version 2.0. -------------------------------------------------------------------------------- /backend/migrations/legacy/015_add_login_attempts_table.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts'); 3 | 4 | if (!hasLoginAttemptsTable) { 5 | return knex.schema.createTable('login_attempts', table => { 6 | table.increments('id').primary(); 7 | table.string('identifier').notNullable(); // username or email 8 | table.string('ip_address', 45).notNullable(); // IPv4 or IPv6 9 | table.text('user_agent'); 10 | table.timestamp('attempt_time').defaultTo(knex.fn.now()); 11 | table.boolean('success').defaultTo(false); 12 | 13 | // Indexes for performance 14 | table.index('identifier'); 15 | table.index('attempt_time'); 16 | table.index(['identifier', 'success', 'attempt_time']); 17 | }); 18 | } 19 | }; 20 | 21 | exports.down = function(knex) { 22 | return knex.schema.dropTableIfExists('login_attempts'); 23 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report issues or improvements needed in documentation 4 | title: '[DOCS] ' 5 | labels: 'documentation' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What documentation needs improvement?** 11 | Please specify which document or section needs attention: 12 | - [ ] README.md 13 | - [ ] DEPLOYMENT.md 14 | - [ ] CONTRIBUTING.md 15 | - [ ] API Documentation 16 | - [ ] Code Comments 17 | - [ ] Other: ___________ 18 | 19 | **Describe the issue** 20 | What's wrong or missing in the documentation? 21 | 22 | **Suggested improvement** 23 | How would you improve this documentation? 24 | 25 | **Target audience** 26 | Who is this documentation for? 27 | - [ ] New users setting up PicPeak 28 | - [ ] Developers contributing to the project 29 | - [ ] System administrators 30 | - [ ] End users (photographers/clients) 31 | 32 | **Additional context** 33 | Add any other context, examples, or references here. -------------------------------------------------------------------------------- /frontend/src/i18n/config.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import HttpBackend from 'i18next-http-backend'; 5 | 6 | import enTranslations from './locales/en.json'; 7 | import deTranslations from './locales/de.json'; 8 | 9 | i18n 10 | .use(HttpBackend) 11 | .use(LanguageDetector) 12 | .use(initReactI18next) 13 | .init({ 14 | fallbackLng: 'en', 15 | debug: false, 16 | 17 | resources: { 18 | en: { 19 | translation: enTranslations, 20 | }, 21 | de: { 22 | translation: deTranslations, 23 | }, 24 | }, 25 | 26 | interpolation: { 27 | escapeValue: false, 28 | }, 29 | 30 | detection: { 31 | order: ['localStorage', 'cookie', 'navigator', 'htmlTag'], 32 | caches: ['localStorage', 'cookie'], 33 | }, 34 | }); 35 | 36 | export default i18n; -------------------------------------------------------------------------------- /frontend/src/utils/accessControl.ts: -------------------------------------------------------------------------------- 1 | export const normalizeRequirePassword = (value: unknown, defaultValue = true): boolean => { 2 | if (value === undefined || value === null) { 3 | return defaultValue; 4 | } 5 | 6 | if (typeof value === 'boolean') { 7 | return value; 8 | } 9 | 10 | if (typeof value === 'number') { 11 | return value !== 0; 12 | } 13 | 14 | if (typeof value === 'string') { 15 | const normalized = value.trim().toLowerCase(); 16 | if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { 17 | return false; 18 | } 19 | if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { 20 | return true; 21 | } 22 | } 23 | 24 | return defaultValue; 25 | }; 26 | 27 | export const isGalleryPublic = (value: unknown, defaultValue = true): boolean => { 28 | return !normalizeRequirePassword(value, defaultValue); 29 | }; 30 | -------------------------------------------------------------------------------- /frontend/nginx.dev.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | root /usr/share/nginx/html; 5 | 6 | # API proxy to backend 7 | location /api { 8 | proxy_pass http://backend:3000; 9 | proxy_http_version 1.1; 10 | proxy_set_header Host $host; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | } 15 | 16 | # Photos proxy to backend 17 | location /photos { 18 | proxy_pass http://backend:3000; 19 | proxy_http_version 1.1; 20 | proxy_set_header Host $host; 21 | } 22 | 23 | # Health check 24 | location /health { 25 | access_log off; 26 | return 200 "healthy\n"; 27 | add_header Content-Type text/plain; 28 | } 29 | 30 | # SPA fallback 31 | location / { 32 | try_files $uri $uri/ /index.html; 33 | } 34 | } -------------------------------------------------------------------------------- /frontend/src/hooks/useWatermarkSettings.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { api } from '../config/api'; 3 | 4 | export function useWatermarkSettings() { 5 | const [watermarkEnabled, setWatermarkEnabled] = useState(false); 6 | const [loading, setLoading] = useState(true); 7 | 8 | useEffect(() => { 9 | const fetchSettings = async () => { 10 | try { 11 | // Use public settings endpoint that doesn't require authentication 12 | const response = await api.get('/public/settings'); 13 | setWatermarkEnabled(response.data.branding_watermark_enabled || false); 14 | } catch (error) { 15 | console.error('Failed to fetch watermark settings:', error); 16 | // Default to false if we can't fetch settings 17 | setWatermarkEnabled(false); 18 | } finally { 19 | setLoading(false); 20 | } 21 | }; 22 | 23 | fetchSettings(); 24 | }, []); 25 | 26 | return { watermarkEnabled, loading }; 27 | } -------------------------------------------------------------------------------- /backend/migrations/legacy/006_add_photo_counter_to_categories.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add photo_counter column to photo_categories table 3 | await knex.schema.alterTable('photo_categories', function(table) { 4 | table.integer('photo_counter').defaultTo(0).notNullable(); 5 | }); 6 | 7 | // Initialize counters based on existing photos 8 | const categories = await knex('photo_categories').select('id'); 9 | 10 | for (const category of categories) { 11 | const photoCount = await knex('photos') 12 | .where('category_id', category.id) 13 | .count('id as count') 14 | .first(); 15 | 16 | if (photoCount && photoCount.count > 0) { 17 | await knex('photo_categories') 18 | .where('id', category.id) 19 | .update({ photo_counter: photoCount.count }); 20 | } 21 | } 22 | }; 23 | 24 | exports.down = async function(knex) { 25 | await knex.schema.alterTable('photo_categories', function(table) { 26 | table.dropColumn('photo_counter'); 27 | }); 28 | }; -------------------------------------------------------------------------------- /backend/migrations/legacy/014_add_host_name_to_events_duplicate.js: -------------------------------------------------------------------------------- 1 | const { db } = require('../../src/database/db'); 2 | 3 | async function up() { 4 | // Check if host_name column already exists 5 | const hasHostName = await db.schema.hasColumn('events', 'host_name'); 6 | 7 | if (!hasHostName) { 8 | await db.schema.table('events', (table) => { 9 | table.string('host_name').after('event_date'); 10 | }); 11 | 12 | console.log('Added host_name column to events table'); 13 | } 14 | } 15 | 16 | async function down() { 17 | await db.schema.table('events', (table) => { 18 | table.dropColumn('host_name'); 19 | }); 20 | } 21 | 22 | module.exports = { up, down }; 23 | 24 | // Run migration if called directly 25 | if (require.main === module) { 26 | up() 27 | .then(() => { 28 | console.log('Migration completed successfully'); 29 | process.exit(0); 30 | }) 31 | .catch((error) => { 32 | console.error('Migration failed:', error); 33 | process.exit(1); 34 | }); 35 | } -------------------------------------------------------------------------------- /backend/migrations/legacy/024_fix_boolean_compatibility.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fix boolean compatibility issues between PostgreSQL and SQLite 3 | * This migration updates the database configuration and existing data 4 | */ 5 | 6 | exports.up = async function(knex) { 7 | const isPostgres = knex.client.config.client === 'pg'; 8 | 9 | if (!isPostgres) { 10 | // Enable foreign keys for SQLite 11 | await knex.raw('PRAGMA foreign_keys = ON'); 12 | 13 | // Note: SQLite stores booleans as 0/1 14 | // No data migration needed as Knex handles this automatically 15 | // But queries must use formatBoolean() helper 16 | 17 | console.log('SQLite boolean compatibility check:'); 18 | console.log('- SQLite stores booleans as 0/1'); 19 | console.log('- All boolean comparisons should use formatBoolean() helper'); 20 | console.log('- Foreign keys enabled'); 21 | } 22 | 23 | return Promise.resolve(); 24 | }; 25 | 26 | exports.down = async function(knex) { 27 | // No rollback needed 28 | return Promise.resolve(); 29 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_vulnerability.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Security Vulnerability 3 | about: Report security issues privately 4 | title: '[SECURITY] ' 5 | labels: 'security' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.** 11 | 12 | Instead, please email security@example.com with the details. 13 | 14 | For minor security improvements or questions, you can use this template: 15 | 16 | **Type of Security Issue** 17 | - [ ] Authentication/Authorization 18 | - [ ] Data Exposure 19 | - [ ] Input Validation 20 | - [ ] Configuration Issue 21 | - [ ] Dependency Vulnerability 22 | - [ ] Other: ___________ 23 | 24 | **Description** 25 | Brief description of the security concern. 26 | 27 | **Impact** 28 | What could an attacker potentially do? 29 | 30 | **Steps to Reproduce** 31 | If applicable, how can this be reproduced? 32 | 33 | **Suggested Fix** 34 | If you have ideas on how to fix this issue. 35 | 36 | **References** 37 | Any relevant security advisories, CVEs, or documentation. -------------------------------------------------------------------------------- /frontend/src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export { Button } from './Button'; 2 | export { Input } from './Input'; 3 | export { Card, CardHeader, CardContent, CardFooter } from './Card'; 4 | export { Loading, LoadingSkeleton } from './Loading'; 5 | export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary'; 6 | export { 7 | Skeleton, 8 | SkeletonGroup, 9 | SkeletonCard, 10 | SkeletonTable, 11 | SkeletonGalleryGrid, 12 | SkeletonList 13 | } from './Skeleton'; 14 | export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator'; 15 | export { SkipLink } from './SkipLink'; 16 | export { DynamicFavicon } from './DynamicFavicon'; 17 | export { LanguageSelector } from './LanguageSelector'; 18 | export { AuthenticatedImage } from './AuthenticatedImage'; 19 | export { AuthenticatedVideo } from './AuthenticatedVideo'; 20 | export { ProtectedImage } from './ProtectedImage'; 21 | export { ProtectionWarning } from './ProtectionWarning'; 22 | export { ReCaptcha } from './ReCaptcha'; 23 | export { PasswordGenerator } from './PasswordGenerator'; 24 | -------------------------------------------------------------------------------- /backend/src/routes/publicCMS.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { db } = require('../database/db'); 3 | const router = express.Router(); 4 | 5 | // Get public CMS page 6 | router.get('/pages/:slug', async (req, res) => { 7 | try { 8 | const { slug } = req.params; 9 | const { lang = 'en' } = req.query; 10 | 11 | const page = await db('cms_pages').where('slug', slug).first(); 12 | 13 | if (!page) { 14 | return res.status(404).json({ error: 'Page not found' }); 15 | } 16 | 17 | // Return the appropriate language version 18 | const title = lang === 'de' ? page.title_de : page.title_en; 19 | const content = lang === 'de' ? page.content_de : page.content_en; 20 | 21 | res.json({ 22 | title, 23 | content, 24 | slug: page.slug, 25 | updated_at: page.updated_at 26 | }); 27 | } catch (error) { 28 | console.error('Error fetching public CMS page:', error); 29 | res.status(500).json({ error: 'Failed to fetch page' }); 30 | } 31 | }); 32 | 33 | module.exports = router; -------------------------------------------------------------------------------- /frontend/src/services/externalMedia.service.ts: -------------------------------------------------------------------------------- 1 | import { api } from '../config/api'; 2 | 3 | export interface ExternalEntry { name: string; type: 'dir' | 'file'; size?: number; mtime?: string } 4 | 5 | export const externalMediaService = { 6 | async list(pathRel: string = ''): Promise<{ path: string; entries: ExternalEntry[]; canNavigateUp: boolean }> { 7 | const params = new URLSearchParams(); 8 | if (pathRel) params.set('path', pathRel); 9 | const res = await api.get(`/admin/external-media/list?${params.toString()}`); 10 | return res.data; 11 | }, 12 | 13 | async importEvent(eventId: number, externalPath: string, options?: { recursive?: boolean; map?: { individual?: string; collages?: string } }): Promise<{ imported: number; skipped: number; thumbnailsQueued: number }> { 14 | const res = await api.post(`/admin/external-media/events/${eventId}/import-external`, { 15 | external_path: externalPath, 16 | recursive: options?.recursive ?? true, 17 | map: options?.map 18 | }); 19 | return res.data; 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /backend/src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formatters for email content and other text transformations 3 | */ 4 | 5 | /** 6 | * Convert plain text line breaks to HTML line breaks 7 | * @param {string} text - The text to format 8 | * @returns {string} - Text with HTML line breaks 9 | */ 10 | function nl2br(text) { 11 | if (!text) return ''; 12 | 13 | // Normalize line endings 14 | text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); 15 | 16 | // Convert newlines to
tags 17 | return text 18 | .split('\n') 19 | .map(line => line.trim()) 20 | .filter(line => line.length > 0) 21 | .join('
'); 22 | } 23 | 24 | /** 25 | * Format welcome message for email templates 26 | * @param {string} message - The welcome message 27 | * @returns {string} - Formatted message for HTML emails 28 | */ 29 | function formatWelcomeMessage(message) { 30 | if (!message || message.trim() === '') { 31 | return ''; 32 | } 33 | 34 | return nl2br(message); 35 | } 36 | 37 | module.exports = { 38 | nl2br, 39 | formatWelcomeMessage 40 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 paul 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /backend/src/utils/requestIp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolve the originating client IP address, accounting for reverse proxies. 3 | * Returns the first entry from X-Forwarded-For when available, otherwise falls back 4 | * to Express/Node connection properties. 5 | * @param {import('express').Request} req 6 | * @returns {string} 7 | */ 8 | function getClientIp(req) { 9 | if (!req) { 10 | return ''; 11 | } 12 | 13 | const forwardedFor = req.headers['x-forwarded-for']; 14 | 15 | if (typeof forwardedFor === 'string' && forwardedFor.length > 0) { 16 | const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean); 17 | if (firstIp) { 18 | return firstIp; 19 | } 20 | } else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { 21 | const [firstIp] = forwardedFor; 22 | if (firstIp) { 23 | return firstIp.trim(); 24 | } 25 | } 26 | 27 | return ( 28 | req.ip || 29 | req.connection?.remoteAddress || 30 | req.socket?.remoteAddress || 31 | req.connection?.socket?.remoteAddress || 32 | '' 33 | ); 34 | } 35 | 36 | module.exports = { getClientIp }; 37 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // @ts-nocheck 3 | 4 | import { defineConfig } from 'vite' 5 | import react from '@vitejs/plugin-react' 6 | import type { UserConfig as VitestUserConfig } from 'vitest/config' 7 | 8 | // https://vite.dev/config/ 9 | const config: VitestUserConfig = { 10 | plugins: [react()], 11 | build: { 12 | rollupOptions: { 13 | output: { 14 | manualChunks: { 15 | 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 16 | 'ui-vendor': ['lucide-react', 'react-toastify'], 17 | }, 18 | }, 19 | }, 20 | sourcemap: true, 21 | }, 22 | test: { 23 | environment: 'jsdom', 24 | setupFiles: './vitest.setup.ts', 25 | globals: true 26 | }, 27 | server: { 28 | port: 5173, 29 | host: true, 30 | proxy: { 31 | '/api': { 32 | target: 'http://localhost:3002', 33 | changeOrigin: true, 34 | }, 35 | '/photos': { 36 | target: 'http://localhost:3002', 37 | changeOrigin: true, 38 | }, 39 | }, 40 | } 41 | } 42 | 43 | export default defineConfig(config as any) 44 | -------------------------------------------------------------------------------- /backend/scripts/set-admin-password.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const bcrypt = require('bcrypt'); 4 | const path = require('path'); 5 | require('dotenv').config({ path: path.join(__dirname, '../.env') }); 6 | 7 | const knex = require('knex'); 8 | const db = knex({ 9 | client: process.env.DB_CLIENT || 'pg', 10 | connection: { 11 | host: process.env.DB_HOST || 'localhost', 12 | port: process.env.DB_PORT || 5432, 13 | user: process.env.DB_USER || 'picpeak', 14 | password: process.env.DB_PASSWORD || 'picpeak', 15 | database: process.env.DB_NAME || 'picpeak_dev' 16 | } 17 | }); 18 | 19 | async function setAdminPassword() { 20 | try { 21 | const password = 'admin123'; 22 | const hashedPassword = await bcrypt.hash(password, 10); 23 | 24 | await db('admin_users') 25 | .where('username', 'admin') 26 | .update({ 27 | password_hash: hashedPassword, 28 | updated_at: new Date() 29 | }); 30 | 31 | console.log('✅ Admin password set to: admin123'); 32 | process.exit(0); 33 | } catch (error) { 34 | console.error('❌ Error setting password:', error); 35 | process.exit(1); 36 | } 37 | } 38 | 39 | setAdminPassword(); 40 | -------------------------------------------------------------------------------- /scripts/generate-jwt-secret.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Generate a secure JWT secret for PicPeak 4 | 5 | echo "===================================" 6 | echo "JWT Secret Generator for PicPeak" 7 | echo "===================================" 8 | echo "" 9 | 10 | # Generate the secret 11 | SECRET=$(openssl rand -hex 32) 12 | 13 | echo "Your new JWT secret (64 characters):" 14 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 15 | echo "$SECRET" 16 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 17 | echo "" 18 | echo "To use this secret:" 19 | echo "" 20 | echo "1. For Docker Compose (.env file):" 21 | echo " JWT_SECRET=$SECRET" 22 | echo "" 23 | echo "2. For environment variable:" 24 | echo " export JWT_SECRET=$SECRET" 25 | echo "" 26 | echo "3. For systemd service:" 27 | echo " Environment=\"JWT_SECRET=$SECRET\"" 28 | echo "" 29 | echo "⚠️ IMPORTANT:" 30 | echo " - Keep this secret secure and never commit it to version control" 31 | echo " - Use different secrets for different environments" 32 | echo " - Store production secrets in a secure secret management system" 33 | echo " - Rotate secrets regularly (every 90 days recommended)" 34 | echo "" -------------------------------------------------------------------------------- /frontend/src/services/toast.service.ts: -------------------------------------------------------------------------------- 1 | import { toast as toastify } from 'react-toastify'; 2 | import i18n from '../i18n/config'; 3 | 4 | export const toast = { 5 | success: (messageKey: string, interpolations?: Record) => { 6 | const message = i18n.t(messageKey, interpolations); 7 | toastify.success(message); 8 | }, 9 | 10 | error: (messageKey: string, interpolations?: Record) => { 11 | const message = i18n.t(messageKey, interpolations); 12 | toastify.error(message); 13 | }, 14 | 15 | info: (messageKey: string, interpolations?: Record) => { 16 | const message = i18n.t(messageKey, interpolations); 17 | toastify.info(message); 18 | }, 19 | 20 | warning: (messageKey: string, interpolations?: Record) => { 21 | const message = i18n.t(messageKey, interpolations); 22 | toastify.warning(message); 23 | }, 24 | 25 | // For direct messages (not translation keys) 26 | successDirect: (message: string) => toastify.success(message), 27 | errorDirect: (message: string) => toastify.error(message), 28 | infoDirect: (message: string) => toastify.info(message), 29 | warningDirect: (message: string) => toastify.warning(message), 30 | }; -------------------------------------------------------------------------------- /backend/src/routes/admin.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Import sub-routers 5 | const dashboardRoutes = require('./adminDashboard'); 6 | const archiveRoutes = require('./adminArchives'); 7 | const emailRoutes = require('./adminEmail'); 8 | const settingsRoutes = require('./adminSettings'); 9 | const eventsRoutes = require('./adminEvents'); 10 | const photosRoutes = require('./adminPhotos'); 11 | const categoriesRoutes = require('./adminCategories'); 12 | const cmsRoutes = require('./adminCMS'); 13 | const notificationsRoutes = require('./adminNotifications'); 14 | const backupRoutes = require('./adminBackup'); 15 | const restoreRoutes = require('./adminRestore'); 16 | 17 | // Mount sub-routers 18 | router.use('/dashboard', dashboardRoutes); 19 | router.use('/archives', archiveRoutes); 20 | router.use('/email', emailRoutes); 21 | router.use('/settings', settingsRoutes); 22 | router.use('/events', eventsRoutes); 23 | router.use('/events', photosRoutes); 24 | router.use('/categories', categoriesRoutes); 25 | router.use('/cms', cmsRoutes); 26 | router.use('/notifications', notificationsRoutes); 27 | router.use('/backup', backupRoutes); 28 | router.use('/restore', restoreRoutes); 29 | 30 | module.exports = router; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for PicPeak 4 | title: '[FEATURE] ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Use Case** 20 | Please describe how this feature would be used: 21 | - Who would use it? (photographers, clients, admins) 22 | - When would they use it? 23 | - Why is it important? 24 | 25 | **Similar Features** 26 | Are there similar features in: 27 | - PicDrop 28 | - Scrapbook.de 29 | - Other photo sharing platforms 30 | 31 | **Mockups or Examples** 32 | If applicable, add mockups, diagrams, or links to similar implementations. 33 | 34 | **Additional context** 35 | Add any other context or screenshots about the feature request here. 36 | 37 | **Implementation Ideas** 38 | If you have technical ideas about how this could be implemented, please share them. -------------------------------------------------------------------------------- /frontend/src/services/cms.service.ts: -------------------------------------------------------------------------------- 1 | import { api } from '../config/api'; 2 | 3 | export interface CMSPage { 4 | id: number; 5 | slug: string; 6 | title_en: string; 7 | title_de: string; 8 | content_en: string; 9 | content_de: string; 10 | updated_at: string; 11 | } 12 | 13 | export const cmsService = { 14 | // Get all CMS pages 15 | async getPages(): Promise { 16 | const response = await api.get('/admin/cms/pages'); 17 | return response.data; 18 | }, 19 | 20 | // Get a single CMS page 21 | async getPage(slug: string): Promise { 22 | const response = await api.get(`/admin/cms/pages/${slug}`); 23 | return response.data; 24 | }, 25 | 26 | // Update a CMS page 27 | async updatePage(slug: string, data: Partial): Promise { 28 | const response = await api.put(`/admin/cms/pages/${slug}`, data); 29 | return response.data; 30 | }, 31 | 32 | // Get public CMS page (no auth required) 33 | async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { 34 | const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, { 35 | params: { lang } 36 | }); 37 | return response.data; 38 | } 39 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve PicPeak 4 | title: '[BUG] ' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: [e.g. Ubuntu 22.04] 28 | - Browser: [e.g. Chrome 120, Safari 17] 29 | - PicPeak Version: [e.g. 1.0.22] 30 | - Deployment Method: [e.g. Docker Compose, Manual] 31 | - Database: [e.g. PostgreSQL 15, SQLite] 32 | 33 | **Logs** 34 | Please include relevant logs: 35 | ``` 36 | # Backend logs 37 | docker-compose logs backend | tail -50 38 | 39 | # Frontend console errors 40 | [paste any browser console errors] 41 | ``` 42 | 43 | **Additional context** 44 | Add any other context about the problem here. 45 | 46 | **Possible Solution** 47 | If you have an idea how to fix the issue, please describe it here. -------------------------------------------------------------------------------- /backend/migrations/core/040_add_thumbnail_settings.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add thumbnail settings to app_settings table 3 | const thumbnailSettings = [ 4 | { setting_key: 'thumbnail_width', setting_value: 300, setting_type: 'number' }, 5 | { setting_key: 'thumbnail_height', setting_value: 300, setting_type: 'number' }, 6 | { setting_key: 'thumbnail_fit', setting_value: JSON.stringify('cover'), setting_type: 'string' }, 7 | { setting_key: 'thumbnail_quality', setting_value: 85, setting_type: 'number' }, 8 | { setting_key: 'thumbnail_format', setting_value: JSON.stringify('jpeg'), setting_type: 'string' } 9 | ]; 10 | 11 | for (const setting of thumbnailSettings) { 12 | const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); 13 | if (!exists) { 14 | await knex('app_settings').insert({ 15 | ...setting, 16 | updated_at: knex.fn.now() 17 | }); 18 | } 19 | } 20 | }; 21 | 22 | exports.down = async function(knex) { 23 | // Remove thumbnail settings 24 | await knex('app_settings') 25 | .whereIn('setting_key', [ 26 | 'thumbnail_width', 27 | 'thumbnail_height', 28 | 'thumbnail_fit', 29 | 'thumbnail_quality', 30 | 'thumbnail_format' 31 | ]) 32 | .del(); 33 | }; -------------------------------------------------------------------------------- /frontend/src/services/publicSettings.service.ts: -------------------------------------------------------------------------------- 1 | import { api } from '../config/api'; 2 | 3 | export interface PublicSettings { 4 | branding_company_name: string; 5 | branding_company_tagline: string; 6 | branding_support_email: string; 7 | branding_footer_text: string; 8 | branding_watermark_enabled: boolean; 9 | branding_watermark_logo_url: string; 10 | branding_watermark_position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'center'; 11 | branding_watermark_opacity: number; 12 | branding_watermark_size: number; 13 | branding_favicon_url: string; 14 | branding_logo_url: string; 15 | theme_config: any; 16 | default_language: string; 17 | enable_analytics: boolean; 18 | general_date_format: string | { format: string; locale: string }; 19 | enable_recaptcha: boolean; 20 | recaptcha_site_key: string | null; 21 | maintenance_mode: boolean; 22 | umami_enabled: boolean; 23 | umami_url: string | null; 24 | umami_website_id: string | null; 25 | umami_share_url: string | null; 26 | } 27 | 28 | export const publicSettingsService = { 29 | // Get public settings (no authentication required) 30 | async getPublicSettings(): Promise { 31 | const response = await api.get('/public/settings'); 32 | return response.data; 33 | } 34 | }; -------------------------------------------------------------------------------- /backend/migrations/legacy/025_fix_email_queue_updated_at.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fix email_queue table by ensuring it doesn't have updated_at column 3 | * This migration addresses the PostgreSQL error where queries are trying to update 4 | * a non-existent updated_at column 5 | */ 6 | 7 | exports.up = async function(knex) { 8 | // First, check if the column exists 9 | const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at'); 10 | 11 | if (hasUpdatedAt) { 12 | console.log('Found updated_at column in email_queue table, removing it...'); 13 | await knex.schema.table('email_queue', (table) => { 14 | table.dropColumn('updated_at'); 15 | }); 16 | } 17 | 18 | // Also ensure the table has all required columns 19 | const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at'); 20 | if (!hasCreatedAt) { 21 | console.log('Adding missing created_at column to email_queue table...'); 22 | await knex.schema.table('email_queue', (table) => { 23 | table.datetime('created_at').defaultTo(knex.fn.now()); 24 | }); 25 | } 26 | 27 | console.log('email_queue table schema fixed'); 28 | }; 29 | 30 | exports.down = async function(knex) { 31 | // In the down migration, we don't add back updated_at since it shouldn't exist 32 | // This is intentionally left minimal 33 | }; -------------------------------------------------------------------------------- /backend/migrations/core/043_add_public_site_settings.js: -------------------------------------------------------------------------------- 1 | const { 2 | DEFAULT_PUBLIC_SITE_HTML, 3 | } = require('../../src/constants/publicSiteDefaults'); 4 | 5 | exports.up = async function(knex) { 6 | const defaults = [ 7 | { 8 | setting_key: 'general_public_site_enabled', 9 | setting_value: JSON.stringify(false), 10 | setting_type: 'general' 11 | }, 12 | { 13 | setting_key: 'general_public_site_html', 14 | setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()), 15 | setting_type: 'general' 16 | }, 17 | { 18 | setting_key: 'general_public_site_custom_css', 19 | setting_value: JSON.stringify(''), 20 | setting_type: 'general' 21 | } 22 | ]; 23 | 24 | for (const setting of defaults) { 25 | const exists = await knex('app_settings') 26 | .where('setting_key', setting.setting_key) 27 | .first(); 28 | 29 | if (!exists) { 30 | await knex('app_settings').insert({ 31 | ...setting, 32 | updated_at: knex.fn.now() 33 | }); 34 | } 35 | } 36 | }; 37 | 38 | exports.down = async function(knex) { 39 | await knex('app_settings') 40 | .whereIn('setting_key', [ 41 | 'general_public_site_enabled', 42 | 'general_public_site_html', 43 | 'general_public_site_custom_css' 44 | ]) 45 | .del(); 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Photo } from '../../../types'; 3 | 4 | export interface BaseGalleryLayoutProps { 5 | photos: Photo[]; 6 | slug: string; 7 | onPhotoClick: (index: number) => void; 8 | // Optional: open the lightbox with feedback panel visible 9 | onOpenPhotoWithFeedback?: (index: number) => void; 10 | // Notify parent that feedback (like/favorite/rating/comment) changed 11 | onFeedbackChange?: () => void; 12 | onDownload: (photo: Photo, e: React.MouseEvent) => void; 13 | selectedPhotos?: Set; 14 | isSelectionMode?: boolean; 15 | onPhotoSelect?: (photoId: number) => void; 16 | eventName?: string; 17 | eventLogo?: string | null; 18 | eventDate?: string; 19 | expiresAt?: string; 20 | allowDownloads?: boolean; 21 | protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; 22 | useEnhancedProtection?: boolean; 23 | feedbackEnabled?: boolean; 24 | feedbackOptions?: { 25 | allowLikes?: boolean; 26 | allowFavorites?: boolean; 27 | allowRatings?: boolean; 28 | allowComments?: boolean; 29 | requireNameEmail?: boolean; 30 | }; 31 | } 32 | 33 | export abstract class BaseGalleryLayout extends React.Component { 34 | abstract render(): React.ReactNode; 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.override.yml.example: -------------------------------------------------------------------------------- 1 | # docker-compose.override.yml.example 2 | # 3 | # Copy this file to docker-compose.override.yml for local production customizations 4 | # docker-compose.override.yml is git-ignored and will be automatically loaded by Docker Compose 5 | # 6 | # Example customizations: 7 | 8 | version: '3.8' 9 | 10 | services: 11 | # Example: Expose backend port for debugging 12 | # backend: 13 | # ports: 14 | # - "3001:3001" 15 | 16 | # Example: Expose database port for local tools 17 | # db: 18 | # ports: 19 | # - "5432:5432" 20 | 21 | # Example: Custom nginx ports 22 | # nginx: 23 | # ports: 24 | # - "8080:80" 25 | # - "8443:443" 26 | 27 | # Example: Enable Umami web interface 28 | # umami: 29 | # ports: 30 | # - "3000:3000" 31 | 32 | # Example: Use different storage paths 33 | # backend: 34 | # volumes: 35 | # - /mnt/photos:/app/storage 36 | # - /mnt/data:/app/data 37 | 38 | # Example: Development-like setup with code mounting 39 | # backend: 40 | # volumes: 41 | # - ./backend:/app 42 | # - /app/node_modules 43 | # command: npm run dev 44 | 45 | # Example: Add Mailhog for email testing 46 | # mailhog: 47 | # image: mailhog/mailhog:latest 48 | # ports: 49 | # - "1025:1025" 50 | # - "8025:8025" 51 | # restart: unless-stopped -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | error_log /var/log/nginx/error.log warn; 4 | pid /var/run/nginx.pid; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | include /etc/nginx/mime.types; 12 | default_type application/octet-stream; 13 | 14 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 15 | '$status $body_bytes_sent "$http_referer" ' 16 | '"$http_user_agent" "$http_x_forwarded_for"'; 17 | 18 | access_log /var/log/nginx/access.log main; 19 | 20 | sendfile on; 21 | tcp_nopush on; 22 | tcp_nodelay on; 23 | keepalive_timeout 65; 24 | gzip on; 25 | gzip_vary on; 26 | gzip_min_length 1024; 27 | gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; 28 | 29 | # Security headers 30 | add_header X-Frame-Options "SAMEORIGIN" always; 31 | add_header X-Content-Type-Options "nosniff" always; 32 | add_header X-XSS-Protection "1; mode=block" always; 33 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 34 | 35 | # Rate limiting 36 | limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; 37 | limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m; 38 | 39 | include /etc/nginx/sites-enabled/*.conf; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSessionTimeout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback } from 'react'; 2 | import { useAdminAuth } from '../contexts'; 3 | import { api } from '../config/api'; 4 | 5 | // Hook to handle session timeout 6 | export const useSessionTimeout = () => { 7 | const { logout } = useAdminAuth(); 8 | 9 | const handleSessionTimeout = useCallback((error: any) => { 10 | if (error?.response?.data?.code === 'SESSION_TIMEOUT') { 11 | // Clear local auth state 12 | logout(); 13 | // Redirect to login with message 14 | window.location.href = '/admin/login?session=expired'; 15 | return true; 16 | } 17 | return false; 18 | }, [logout]); 19 | 20 | useEffect(() => { 21 | // Add response interceptor to handle session timeout 22 | const interceptor = api.interceptors.response.use( 23 | response => response, 24 | error => { 25 | if (handleSessionTimeout(error)) { 26 | // Don't propagate the error if it was a session timeout 27 | return Promise.reject(new Error('Session expired')); 28 | } 29 | return Promise.reject(error); 30 | } 31 | ); 32 | 33 | // Clean up interceptor on unmount 34 | return () => { 35 | api.interceptors.response.eject(interceptor); 36 | }; 37 | }, [handleSessionTimeout]); 38 | 39 | return { handleSessionTimeout }; 40 | }; -------------------------------------------------------------------------------- /backend/migrations/legacy/008_add_language_support_to_email_templates.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add language-specific columns to email_templates 3 | await knex.schema.alterTable('email_templates', function(table) { 4 | // Add English versions (rename existing columns for consistency) 5 | table.renameColumn('subject', 'subject_en'); 6 | table.renameColumn('body_html', 'body_html_en'); 7 | table.renameColumn('body_text', 'body_text_en'); 8 | 9 | // Add German versions 10 | table.string('subject_de'); 11 | table.text('body_html_de'); 12 | table.text('body_text_de'); 13 | }); 14 | 15 | // Copy existing values to German columns as defaults 16 | await knex('email_templates').update({ 17 | subject_de: knex.raw('subject_en'), 18 | body_html_de: knex.raw('body_html_en'), 19 | body_text_de: knex.raw('body_text_en') 20 | }); 21 | }; 22 | 23 | exports.down = async function(knex) { 24 | await knex.schema.alterTable('email_templates', function(table) { 25 | // Remove German columns 26 | table.dropColumn('subject_de'); 27 | table.dropColumn('body_html_de'); 28 | table.dropColumn('body_text_de'); 29 | 30 | // Rename columns back 31 | table.renameColumn('subject_en', 'subject'); 32 | table.renameColumn('body_html_en', 'body_html'); 33 | table.renameColumn('body_text_en', 'body_text'); 34 | }); 35 | }; -------------------------------------------------------------------------------- /frontend/src/components/GlobalThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { useTheme } from '../contexts/ThemeContext'; 4 | import { api } from '../config/api'; 5 | 6 | interface GlobalThemeProviderProps { 7 | children: React.ReactNode; 8 | } 9 | 10 | export const GlobalThemeProvider: React.FC = ({ children }) => { 11 | const { setTheme } = useTheme(); 12 | const themeAppliedRef = useRef(false); 13 | 14 | // Fetch public settings including theme config 15 | const { data: settingsData } = useQuery({ 16 | queryKey: ['global-theme-settings'], 17 | queryFn: async () => { 18 | const response = await api.get('/public/settings'); 19 | return response.data; 20 | }, 21 | staleTime: 5 * 60 * 1000, // Cache for 5 minutes 22 | }); 23 | 24 | // Apply global theme when settings are loaded (but not on gallery pages) 25 | useEffect(() => { 26 | // Skip if we're on a gallery page - gallery pages handle their own themes 27 | const isGalleryPage = window.location.pathname.includes('/gallery/'); 28 | 29 | if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { 30 | themeAppliedRef.current = true; 31 | setTheme(settingsData.theme_config); 32 | } 33 | }, [settingsData, setTheme]); 34 | 35 | return <>{children}; 36 | }; -------------------------------------------------------------------------------- /backend/migrations/legacy/019_fix_email_templates_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Check current column structure 3 | const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en'); 4 | const hasSubject = await knex.schema.hasColumn('email_templates', 'subject'); 5 | 6 | if (hasSubjectEn && !hasSubject) { 7 | // The language migration was applied, need to add back basic columns 8 | await knex.schema.alterTable('email_templates', function(table) { 9 | table.string('subject'); 10 | table.text('body_html'); 11 | table.text('body_text'); 12 | }); 13 | 14 | // Copy English values to the basic columns 15 | await knex('email_templates').update({ 16 | subject: knex.raw('subject_en'), 17 | body_html: knex.raw('body_html_en'), 18 | body_text: knex.raw('body_text_en') 19 | }); 20 | } 21 | }; 22 | 23 | exports.down = async function(knex) { 24 | // Check if we have the basic columns 25 | const hasSubject = await knex.schema.hasColumn('email_templates', 'subject'); 26 | const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en'); 27 | 28 | if (hasSubject && hasSubjectEn) { 29 | await knex.schema.alterTable('email_templates', function(table) { 30 | table.dropColumn('subject'); 31 | table.dropColumn('body_html'); 32 | table.dropColumn('body_text'); 33 | }); 34 | } 35 | }; -------------------------------------------------------------------------------- /frontend/src/components/common/DynamicFavicon.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { getApiBaseUrl, buildResourceUrl } from '../../utils/url'; 4 | 5 | export const DynamicFavicon: React.FC = () => { 6 | const { data: settings } = useQuery({ 7 | queryKey: ['public-settings'], 8 | queryFn: async () => { 9 | try { 10 | const response = await fetch(`${getApiBaseUrl()}/public/settings`); 11 | if (response.ok) { 12 | return response.json(); 13 | } 14 | return null; 15 | } catch { 16 | return null; 17 | } 18 | }, 19 | staleTime: 5 * 60 * 1000, // 5 minutes 20 | }); 21 | 22 | useEffect(() => { 23 | if (settings?.branding_favicon_url) { 24 | // Remove existing favicon links 25 | const existingFavicons = document.querySelectorAll("link[rel*='icon']"); 26 | existingFavicons.forEach(favicon => favicon.remove()); 27 | 28 | // Create new favicon link 29 | const link = document.createElement('link'); 30 | link.rel = 'icon'; 31 | link.type = 'image/png'; 32 | link.href = settings.branding_favicon_url.startsWith('http') 33 | ? settings.branding_favicon_url 34 | : buildResourceUrl(settings.branding_favicon_url); 35 | 36 | document.head.appendChild(link); 37 | } 38 | }, [settings?.branding_favicon_url]); 39 | 40 | return null; 41 | }; -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/migrations/legacy/016_add_auth_security_columns.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Check if columns already exist to avoid conflicts 3 | const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at'); 4 | const hasLastLoginIp = await knex.schema.hasColumn('admin_users', 'last_login_ip'); 5 | const hasTwoFactorEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled'); 6 | const hasTwoFactorSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret'); 7 | 8 | return knex.schema.table('admin_users', table => { 9 | // Add password change tracking 10 | if (!hasPasswordChangedAt) { 11 | table.timestamp('password_changed_at').nullable(); 12 | } 13 | 14 | // Add last login IP for security monitoring 15 | if (!hasLastLoginIp) { 16 | table.string('last_login_ip', 45).nullable(); 17 | } 18 | 19 | // Add account security flags 20 | if (!hasTwoFactorEnabled) { 21 | table.boolean('two_factor_enabled').defaultTo(false); 22 | } 23 | if (!hasTwoFactorSecret) { 24 | table.string('two_factor_secret').nullable(); 25 | } 26 | }); 27 | }; 28 | 29 | exports.down = function(knex) { 30 | return knex.schema.table('admin_users', table => { 31 | table.dropColumn('password_changed_at'); 32 | table.dropColumn('last_login_ip'); 33 | table.dropColumn('two_factor_enabled'); 34 | table.dropColumn('two_factor_secret'); 35 | }); 36 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Environment files 8 | .env 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | # Docker override file 15 | docker-compose.override.yml 16 | 17 | # Security - Never commit credentials 18 | ADMIN_CREDENTIALS.txt 19 | ADMIN_PASSWORD_RESET.txt 20 | *_CREDENTIALS.txt 21 | *_PASSWORD_RESET.txt 22 | 23 | # Storage and data 24 | storage/events/active/* 25 | storage/events/archived/* 26 | storage/thumbnails/* 27 | data/*.db 28 | data/*.db-journal 29 | logs/* 30 | 31 | # Build outputs 32 | build/ 33 | dist/ 34 | *.log 35 | 36 | # OS files 37 | .DS_Store 38 | Thumbs.db 39 | 40 | # IDE files 41 | .vscode/ 42 | .idea/ 43 | *.swp 44 | *.swo 45 | 46 | # Test coverage 47 | coverage/ 48 | .nyc_output/ 49 | 50 | # Temporary files 51 | *.tmp 52 | *.temp 53 | 54 | # Backup and test directories 55 | backups/ 56 | test-archiver/ 57 | 58 | # Keep directory structure 59 | !storage/events/active/.gitkeep 60 | !storage/events/archived/.gitkeep 61 | !storage/thumbnails/.gitkeep 62 | !data/.gitkeep 63 | !logs/.gitkeep 64 | 65 | # development files 66 | backend/.swarm/ 67 | .claudedocs/ 68 | backend/data/ 69 | backend/docs/ 70 | backend/logs/ 71 | logs/ 72 | storage/ 73 | data/ 74 | certbot/ 75 | 76 | # Ignore local contributor guide copy 77 | AGENTS.md 78 | CLAUDE.md 79 | 80 | # Local artifacts from browser tooling 81 | .playwright-mcp/ 82 | 83 | # Local SQLite files in backend 84 | backend/*.sqlite* 85 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFocusTrap.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const useFocusTrap = (isActive: boolean) => { 4 | const containerRef = useRef(null); 5 | 6 | useEffect(() => { 7 | if (!isActive || !containerRef.current) return; 8 | 9 | const container = containerRef.current; 10 | const focusableElements = container.querySelectorAll( 11 | 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' 12 | ); 13 | 14 | const firstFocusable = focusableElements[0] as HTMLElement; 15 | const lastFocusable = focusableElements[focusableElements.length - 1] as HTMLElement; 16 | 17 | // Focus first element when trap is activated 18 | firstFocusable?.focus(); 19 | 20 | const handleKeyDown = (e: KeyboardEvent) => { 21 | if (e.key !== 'Tab') return; 22 | 23 | if (e.shiftKey) { 24 | // Shift + Tab 25 | if (document.activeElement === firstFocusable) { 26 | e.preventDefault(); 27 | lastFocusable?.focus(); 28 | } 29 | } else { 30 | // Tab 31 | if (document.activeElement === lastFocusable) { 32 | e.preventDefault(); 33 | firstFocusable?.focus(); 34 | } 35 | } 36 | }; 37 | 38 | container.addEventListener('keydown', handleKeyDown); 39 | 40 | return () => { 41 | container.removeEventListener('keydown', handleKeyDown); 42 | }; 43 | }, [isActive]); 44 | 45 | return containerRef; 46 | }; -------------------------------------------------------------------------------- /backend/migrations/core/046_add_customer_contact_fields.js: -------------------------------------------------------------------------------- 1 | const { addColumnIfNotExists } = require('../helpers'); 2 | 3 | exports.up = async function up(knex) { 4 | await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => { 5 | table.string('customer_name'); 6 | }); 7 | 8 | await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => { 9 | table.string('customer_email'); 10 | }); 11 | 12 | // Backfill new columns from legacy host_* fields 13 | const client = knex?.client?.config?.client; 14 | 15 | if (client === 'pg') { 16 | await knex.raw(` 17 | UPDATE events 18 | SET customer_name = COALESCE(customer_name, host_name), 19 | customer_email = COALESCE(customer_email, host_email) 20 | `); 21 | } else { 22 | // SQLite fallback 23 | await knex('events').update({ 24 | customer_name: knex.raw('COALESCE(customer_name, host_name)'), 25 | customer_email: knex.raw('COALESCE(customer_email, host_email)') 26 | }); 27 | } 28 | }; 29 | 30 | exports.down = async function down(knex) { 31 | const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name'); 32 | if (hasCustomerName) { 33 | await knex.schema.alterTable('events', (table) => { 34 | table.dropColumn('customer_name'); 35 | }); 36 | } 37 | 38 | const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email'); 39 | if (hasCustomerEmail) { 40 | await knex.schema.alterTable('events', (table) => { 41 | table.dropColumn('customer_email'); 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /frontend/src/components/admin/VersionInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import { useTranslation } from 'react-i18next'; 4 | import { Info } from 'lucide-react'; 5 | import { api } from '../../config/api'; 6 | import packageJson from '../../../package.json'; 7 | 8 | // Frontend version from package.json 9 | const FRONTEND_VERSION = packageJson.version; 10 | 11 | interface SystemVersion { 12 | backend: string; 13 | frontend: string; 14 | node: string; 15 | environment: string; 16 | } 17 | 18 | async function fetchSystemVersion(): Promise { 19 | const response = await api.get('/admin/system/version'); 20 | return response.data; 21 | } 22 | 23 | export const VersionInfo: React.FC = () => { 24 | const { t } = useTranslation(); 25 | const { data: versionInfo } = useQuery({ 26 | queryKey: ['system-version'], 27 | queryFn: fetchSystemVersion, 28 | staleTime: 5 * 60 * 1000, // Cache for 5 minutes 29 | }); 30 | 31 | return ( 32 |
33 |
34 | 35 | {t('admin.version')} 36 |
37 |
38 |
Frontend: v{FRONTEND_VERSION}
39 | {versionInfo && ( 40 |
Backend: v{versionInfo.backend}
41 | )} 42 |
43 |
44 | ); 45 | }; -------------------------------------------------------------------------------- /frontend/src/services/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { api } from '../config/api'; 2 | 3 | export interface PhotoCategory { 4 | id: number; 5 | name: string; 6 | slug: string; 7 | is_global: boolean; 8 | event_id: number | null; 9 | created_at: string; 10 | } 11 | 12 | export interface CreateCategoryData { 13 | name: string; 14 | slug?: string; 15 | is_global?: boolean; 16 | event_id?: number; 17 | } 18 | 19 | export const categoriesService = { 20 | // Get all global categories 21 | async getGlobalCategories(): Promise { 22 | const response = await api.get('/admin/categories/global'); 23 | return response.data; 24 | }, 25 | 26 | // Get categories for a specific event (global + event-specific) 27 | async getEventCategories(eventId: number): Promise { 28 | const response = await api.get(`/admin/categories/event/${eventId}`); 29 | return response.data; 30 | }, 31 | 32 | // Create a new category 33 | async createCategory(data: CreateCategoryData): Promise { 34 | const response = await api.post('/admin/categories', data); 35 | return response.data; 36 | }, 37 | 38 | // Update a category 39 | async updateCategory(id: number, name: string): Promise { 40 | const response = await api.put(`/admin/categories/${id}`, { name }); 41 | return response.data; 42 | }, 43 | 44 | // Delete a category 45 | async deleteCategory(id: number): Promise { 46 | await api.delete(`/admin/categories/${id}`); 47 | } 48 | }; -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | rules: { 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], 25 | 'react-hooks/rules-of-hooks': 'off', 26 | 'react-hooks/exhaustive-deps': 'warn', 27 | 'no-useless-escape': 'off', 28 | 'no-case-declarations': 'off', 29 | 'prefer-const': 'off', 30 | 'no-control-regex': 'off', 31 | 'no-useless-catch': 'off', 32 | 'react-refresh/only-export-components': 'off', 33 | 'no-empty': 'off', 34 | 'no-debugger': 'off', 35 | '@typescript-eslint/no-unused-expressions': 'off', 36 | '@typescript-eslint/ban-ts-comment': 'off', 37 | }, 38 | }, 39 | { 40 | files: ['**/*.d.ts'], 41 | rules: { 42 | '@typescript-eslint/no-explicit-any': 'off', 43 | '@typescript-eslint/no-unused-vars': 'off', 44 | }, 45 | }, 46 | ]) 47 | -------------------------------------------------------------------------------- /backend/migrations/legacy/027_add_language_preferences.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add language column to events table if it doesn't exist 3 | const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language'); 4 | if (!hasLanguageInEvents) { 5 | await knex.schema.alterTable('events', function(table) { 6 | table.string('language', 5).defaultTo('en'); 7 | }); 8 | } 9 | 10 | // Add default_language to email_configs if it doesn't exist 11 | const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language'); 12 | if (!hasDefaultLanguage) { 13 | await knex.schema.alterTable('email_configs', function(table) { 14 | table.string('default_language', 5).defaultTo('en'); 15 | }); 16 | } 17 | 18 | // Set default language to German for the existing email config 19 | await knex('email_configs') 20 | .update({ 21 | default_language: 'de' 22 | }); 23 | }; 24 | 25 | exports.down = async function(knex) { 26 | // Remove language column from events table 27 | const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language'); 28 | if (hasLanguageInEvents) { 29 | await knex.schema.alterTable('events', function(table) { 30 | table.dropColumn('language'); 31 | }); 32 | } 33 | 34 | // Remove default_language from email_configs 35 | const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language'); 36 | if (hasDefaultLanguage) { 37 | await knex.schema.alterTable('email_configs', function(table) { 38 | table.dropColumn('default_language'); 39 | }); 40 | } 41 | }; -------------------------------------------------------------------------------- /backend/migrations/README.md: -------------------------------------------------------------------------------- 1 | # Database Migrations 2 | 3 | This directory contains database migrations for the Wedding Photo Sharing platform. 4 | 5 | ## Directory Structure 6 | 7 | ### `/core` 8 | Essential migrations that are always run for new deployments. These include: 9 | - `init.js` - Initial database schema creation 10 | - Backup service tables (029-035) 11 | - Gallery feedback tables (033) 12 | 13 | ### `/legacy` 14 | Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns. 15 | 16 | ## For New Deployments 17 | 18 | If you're deploying this application for the first time: 19 | 1. The `initializeDatabase()` function in `src/database/db.js` will create all necessary tables 20 | 2. Only migrations in the `/core` directory will be run 21 | 3. This ensures a clean, optimized database schema 22 | 23 | ## For Existing Deployments 24 | 25 | If you're upgrading from an older version: 26 | 1. All migrations (both core and legacy) will be run in sequence 27 | 2. The migration system tracks which migrations have been applied 28 | 3. Only new migrations will be executed 29 | 30 | ## Running Migrations 31 | 32 | ```bash 33 | # Development 34 | npm run migrate 35 | 36 | # Production 37 | npm run migrate:prod 38 | ``` 39 | 40 | ## Note on Duplicate Migration Numbers 41 | 42 | The legacy directory contains renamed duplicates: 43 | - `014_add_host_name_to_events_duplicate.js` (was duplicate of 014) 44 | - `027_add_rate_limit_settings_duplicate.js` (was duplicate of 027) 45 | 46 | These have been renamed to avoid conflicts while preserving the migration history. -------------------------------------------------------------------------------- /backend/migrations/core/045_add_max_files_per_upload_setting.js: -------------------------------------------------------------------------------- 1 | const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings'); 2 | 3 | exports.up = async function up(knex) { 4 | const settingKey = 'general_max_files_per_upload'; 5 | 6 | const existing = await knex('app_settings') 7 | .where({ setting_key: settingKey }) 8 | .first(); 9 | 10 | if (existing) { 11 | // Normalize existing value into allowed bounds 12 | let parsedValue; 13 | try { 14 | parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null; 15 | } catch { 16 | parsedValue = existing.setting_value; 17 | } 18 | 19 | const numeric = Number(parsedValue); 20 | let normalized = DEFAULT_MAX_FILES_PER_UPLOAD; 21 | if (Number.isFinite(numeric) && numeric >= 1) { 22 | normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric)); 23 | } 24 | 25 | if (normalized !== numeric) { 26 | await knex('app_settings') 27 | .where({ setting_key: settingKey }) 28 | .update({ 29 | setting_value: JSON.stringify(normalized), 30 | updated_at: new Date() 31 | }); 32 | } 33 | return; 34 | } 35 | 36 | await knex('app_settings').insert({ 37 | setting_key: settingKey, 38 | setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD), 39 | setting_type: 'general', 40 | updated_at: new Date() 41 | }); 42 | }; 43 | 44 | exports.down = async function down(knex) { 45 | await knex('app_settings') 46 | .where({ setting_key: 'general_max_files_per_upload' }) 47 | .del(); 48 | }; 49 | -------------------------------------------------------------------------------- /frontend/src/components/admin/MaintenanceBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AlertTriangle, X } from 'lucide-react'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | import { settingsService } from '../../services/settings.service'; 5 | 6 | export const MaintenanceBanner: React.FC = () => { 7 | const [dismissed, setDismissed] = React.useState(false); 8 | 9 | const { data: settings } = useQuery({ 10 | queryKey: ['admin-settings'], 11 | queryFn: () => settingsService.getAllSettings(), 12 | refetchInterval: 60000 // Check every minute 13 | }); 14 | 15 | const isMaintenanceMode = settings?.general_maintenance_mode === true || 16 | settings?.general_maintenance_mode === 'true'; 17 | 18 | if (!isMaintenanceMode || dismissed) { 19 | return null; 20 | } 21 | 22 | return ( 23 |
24 |
25 |
26 |
27 | 28 |

29 | Maintenance mode is currently enabled. Public access to galleries is restricted. 30 |

31 |
32 | 38 |
39 |
40 |
41 | ); 42 | }; -------------------------------------------------------------------------------- /frontend/src/components/common/ReCaptcha.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import ReCAPTCHA from 'react-google-recaptcha'; 3 | import { useQuery } from '@tanstack/react-query'; 4 | import { getApiBaseUrl } from '../../utils/url'; 5 | 6 | interface ReCaptchaProps { 7 | onChange: (token: string | null) => void; 8 | onExpired?: () => void; 9 | size?: 'normal' | 'compact'; 10 | } 11 | 12 | export const ReCaptcha: React.FC = ({ 13 | onChange, 14 | onExpired, 15 | size = 'normal' 16 | }) => { 17 | const recaptchaRef = React.useRef(null); 18 | const [siteKey, setSiteKey] = useState(''); 19 | 20 | // Fetch public settings to get reCAPTCHA site key 21 | const { data: settings } = useQuery({ 22 | queryKey: ['public-settings'], 23 | queryFn: async () => { 24 | const response = await fetch(`${getApiBaseUrl()}/public/settings`); 25 | return response.json(); 26 | }, 27 | staleTime: 5 * 60 * 1000, // Cache for 5 minutes 28 | }); 29 | 30 | useEffect(() => { 31 | if (settings?.recaptcha_site_key) { 32 | setSiteKey(settings.recaptcha_site_key); 33 | } 34 | }, [settings]); 35 | 36 | // If reCAPTCHA is not enabled or site key is not available, return null 37 | if (!settings?.enable_recaptcha || !siteKey) { 38 | return null; 39 | } 40 | 41 | return ( 42 |
43 | 51 |
52 | ); 53 | }; 54 | 55 | export default ReCaptcha; -------------------------------------------------------------------------------- /frontend/src/utils/cleanupGalleryAuth.ts: -------------------------------------------------------------------------------- 1 | // Cleanup function to remove old gallery authentication data 2 | export const cleanupOldGalleryAuth = () => { 3 | // Remove old global gallery authentication 4 | localStorage.removeItem('gallery_event'); 5 | localStorage.removeItem('gallery_token'); // Remove old global token format 6 | 7 | // Remove any corrupted or old gallery tokens from localStorage 8 | const keysToRemove: string[] = []; 9 | for (let i = 0; i < localStorage.length; i++) { 10 | const key = localStorage.key(i); 11 | if (key && (key.startsWith('gallery_token') || key.startsWith('gallery_event'))) { 12 | keysToRemove.push(key); 13 | } 14 | } 15 | 16 | keysToRemove.forEach(key => { 17 | localStorage.removeItem(key); 18 | }); 19 | 20 | // Remove old gallery token from cookies if it exists 21 | document.cookie = 'gallery_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; 22 | 23 | // Also clear session storage 24 | sessionStorage.removeItem('gallery_event'); 25 | sessionStorage.removeItem('gallery_token'); 26 | sessionStorage.removeItem('gallery_active_slug'); 27 | 28 | // Remove slug-specific session storage entries as well 29 | try { 30 | const sessionKeysToRemove: string[] = []; 31 | for (let i = 0; i < sessionStorage.length; i += 1) { 32 | const key = sessionStorage.key(i); 33 | if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) { 34 | sessionKeysToRemove.push(key); 35 | } 36 | } 37 | 38 | sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key)); 39 | } catch { 40 | // Session storage may be unavailable; ignore cleanup failures 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /backend/init-production.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # init-production.sh - Production initialization script 3 | 4 | set -e 5 | 6 | echo "🚀 Initializing PicPeak Production Environment..." 7 | 8 | # Wait for services to be ready 9 | echo "⏳ Waiting for database to be fully ready..." 10 | sleep 3 11 | 12 | # Fix permissions if running as root (shouldn't happen with proper Dockerfile) 13 | if [ "$(id -u)" = "0" ]; then 14 | echo "🔧 Fixing file permissions..." 15 | chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true 16 | fi 17 | 18 | # Create required directories 19 | echo "📁 Creating required directories..." 20 | mkdir -p /app/storage/events/active \ 21 | /app/storage/events/archived \ 22 | /app/storage/thumbnails \ 23 | /app/storage/uploads/logos \ 24 | /app/storage/uploads/favicons \ 25 | /app/data \ 26 | /app/logs 27 | 28 | # Run migrations with safe runner 29 | echo "🗄️ Running database migrations (safe mode)..." 30 | NODE_ENV=production npm run migrate:safe 31 | 32 | # Create admin user if environment variables are set 33 | if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then 34 | echo "👤 Creating admin user..." 35 | node scripts/create-admin.js \ 36 | --email "$ADMIN_EMAIL" \ 37 | --username "${ADMIN_USERNAME:-admin}" \ 38 | --password "$ADMIN_PASSWORD" || echo "Admin user might already exist" 39 | fi 40 | 41 | # Initialize email configuration if variables are set 42 | if [ -n "$SMTP_HOST" ]; then 43 | echo "📧 Email configuration detected via environment variables" 44 | fi 45 | 46 | echo "✅ Production initialization complete!" 47 | echo "🌐 Starting application server..." 48 | 49 | # Start the application 50 | exec node server.js -------------------------------------------------------------------------------- /docs/nginx-fix.md: -------------------------------------------------------------------------------- 1 | # Nginx Configuration Fix for Photo Authentication 2 | 3 | If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy. 4 | 5 | ## Common Issue 6 | 7 | The `Authorization` header is often not passed through by default in nginx proxy configurations. 8 | 9 | ## Fix 10 | 11 | Add these lines to your nginx configuration for the PicPeak location block: 12 | 13 | ```nginx 14 | location / { 15 | proxy_pass http://localhost:3001; 16 | 17 | # Important: Pass the Authorization header 18 | proxy_pass_header Authorization; 19 | proxy_set_header Authorization $http_authorization; 20 | 21 | # Other standard proxy headers 22 | proxy_set_header Host $host; 23 | proxy_set_header X-Real-IP $remote_addr; 24 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 25 | proxy_set_header X-Forwarded-Proto $scheme; 26 | } 27 | ``` 28 | 29 | ## Alternative Fix Using Traefik 30 | 31 | If using Traefik, ensure headers are passed: 32 | 33 | ```yaml 34 | services: 35 | picpeak: 36 | labels: 37 | - "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization=" 38 | ``` 39 | 40 | ## Testing 41 | 42 | 1. Check if Authorization header is reaching the backend: 43 | ```bash 44 | curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v 45 | ``` 46 | 47 | 2. Check nginx logs to see if the header is present: 48 | ```bash 49 | tail -f /var/log/nginx/access.log 50 | ``` 51 | 52 | ## Docker Compose Fix 53 | 54 | If using docker-compose with nginx proxy, add: 55 | 56 | ```yaml 57 | environment: 58 | - NGINX_PROXY_PASS_HEADER=Authorization 59 | ``` -------------------------------------------------------------------------------- /frontend/Dockerfile.prod: -------------------------------------------------------------------------------- 1 | # Build stage with dynamic API URL 2 | FROM node:20-alpine AS builder 3 | 4 | # Accept build args for API URL 5 | ARG VITE_API_URL=/api 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy package files 11 | COPY package*.json ./ 12 | 13 | # Install dependencies 14 | RUN npm ci --legacy-peer-deps 15 | 16 | # Copy source files 17 | COPY . . 18 | 19 | # Set environment variable for build 20 | ENV VITE_API_URL=$VITE_API_URL 21 | 22 | # Build the application 23 | RUN npm run build 24 | 25 | # Production stage 26 | FROM nginx:alpine 27 | 28 | # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) 29 | RUN apk upgrade --no-cache 30 | 31 | # Install runtime dependencies 32 | RUN apk add --no-cache curl 33 | 34 | # Remove default nginx config 35 | RUN rm -rf /etc/nginx/conf.d/* 36 | 37 | # Copy custom nginx config 38 | COPY nginx.conf /etc/nginx/conf.d/default.conf 39 | 40 | # Copy built application from builder stage 41 | COPY --from=builder /app/dist /usr/share/nginx/html 42 | 43 | # Create a script to inject runtime config 44 | RUN cat > /usr/share/nginx/html/config.js << 'EOF' 45 | window.__RUNTIME_CONFIG__ = { 46 | API_URL: '/api' 47 | }; 48 | EOF 49 | 50 | # Set permissions 51 | RUN chown -R nginx:nginx /usr/share/nginx/html && \ 52 | chown -R nginx:nginx /var/cache/nginx && \ 53 | chown -R nginx:nginx /var/log/nginx && \ 54 | touch /var/run/nginx.pid && \ 55 | chown -R nginx:nginx /var/run/nginx.pid 56 | 57 | # Expose port 58 | EXPOSE 80 59 | 60 | # Health check 61 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 62 | CMD curl -f http://localhost/health || exit 1 63 | 64 | # Switch to non-root user 65 | USER nginx 66 | 67 | # Start nginx 68 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the changes and which issue is fixed. Include relevant motivation and context. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Documentation update 15 | - [ ] Performance improvement 16 | - [ ] Code refactoring 17 | 18 | ## How Has This Been Tested? 19 | 20 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. 21 | 22 | - [ ] Unit tests pass (`npm test`) 23 | - [ ] Manual testing completed 24 | - [ ] Tested on Docker deployment 25 | - [ ] Tested on production-like environment 26 | 27 | **Test Configuration**: 28 | * PicPeak Version: 29 | * Node.js Version: 30 | * Database: PostgreSQL / SQLite 31 | * Browser: 32 | 33 | ## Checklist: 34 | 35 | - [ ] My code follows the style guidelines of this project 36 | - [ ] I have performed a self-review of my code 37 | - [ ] I have commented my code, particularly in hard-to-understand areas 38 | - [ ] I have made corresponding changes to the documentation 39 | - [ ] My changes generate no new warnings 40 | - [ ] I have added tests that prove my fix is effective or that my feature works 41 | - [ ] New and existing unit tests pass locally with my changes 42 | - [ ] Any dependent changes have been merged and published 43 | - [ ] I have updated the CHANGELOG.md file 44 | 45 | ## Screenshots (if appropriate): 46 | 47 | ## Additional Notes: 48 | 49 | Add any additional notes, concerns, or discussion points here. -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Backend Environment Variables Example 2 | # Copy this file to .env and update with your values 3 | 4 | # Application 5 | NODE_ENV=production 6 | PORT=3001 7 | 8 | # Security 9 | # Generate with: openssl rand -base64 32 10 | JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 11 | 12 | # URLs (adjust for your domain) 13 | ADMIN_URL=https://photos.example.com 14 | FRONTEND_URL=https://photos.example.com 15 | BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate 16 | 17 | # Database Configuration 18 | DATABASE_CLIENT=pg 19 | DB_HOST=localhost 20 | DB_PORT=5432 21 | DB_USER=picpeak 22 | DB_PASSWORD=your-secure-database-password-change-this 23 | DB_NAME=picpeak 24 | 25 | # Email Configuration (Examples for common providers) 26 | # Gmail example: 27 | # SMTP_HOST=smtp.gmail.com 28 | # SMTP_PORT=587 29 | # SMTP_SECURE=false 30 | # SMTP_USER=your-email@gmail.com 31 | # SMTP_PASS=your-app-specific-password 32 | 33 | # SendGrid example: 34 | SMTP_HOST=smtp.sendgrid.net 35 | SMTP_PORT=587 36 | SMTP_SECURE=false 37 | SMTP_USER=apikey 38 | SMTP_PASS=your-sendgrid-api-key 39 | EMAIL_FROM=noreply@example.com 40 | 41 | # Storage Paths 42 | # Docker deployment: 43 | STORAGE_PATH=/app/storage 44 | EVENTS_PATH=/app/storage/events 45 | ARCHIVE_PATH=/app/storage/events/archived 46 | 47 | # Local development: 48 | # STORAGE_PATH=./storage 49 | # EVENTS_PATH=./storage/events 50 | # ARCHIVE_PATH=./storage/events/archived 51 | 52 | # Analytics Backend Configuration (OPTIONAL) 53 | # Used for server-side tracking only 54 | # Primary configuration should be done through Admin UI > Settings > Analytics 55 | # UMAMI_URL=https://analytics.example.com 56 | # UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab 57 | 58 | # Logging 59 | LOG_LEVEL=info -------------------------------------------------------------------------------- /backend/src/services/recaptcha.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { db } = require('../database/db'); 3 | 4 | async function verifyRecaptcha(token) { 5 | // Check if reCAPTCHA is enabled 6 | const settings = await db('app_settings') 7 | .whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key']) 8 | .select('setting_key', 'setting_value'); 9 | 10 | const settingsMap = {}; 11 | settings.forEach(setting => { 12 | try { 13 | settingsMap[setting.setting_key] = JSON.parse(setting.setting_value); 14 | } catch (e) { 15 | settingsMap[setting.setting_key] = setting.setting_value; 16 | } 17 | }); 18 | 19 | const isEnabled = settingsMap.security_enable_recaptcha === true || 20 | settingsMap.security_enable_recaptcha === 'true'; 21 | const secretKey = settingsMap.security_recaptcha_secret_key; 22 | 23 | // If reCAPTCHA is not enabled, always return true 24 | if (!isEnabled) { 25 | return true; 26 | } 27 | 28 | // If enabled but no token provided, fail 29 | if (!token) { 30 | return false; 31 | } 32 | 33 | // If no secret key configured, log warning but pass 34 | if (!secretKey) { 35 | console.warn('reCAPTCHA enabled but no secret key configured'); 36 | return true; 37 | } 38 | 39 | try { 40 | const response = await axios.post( 41 | 'https://www.google.com/recaptcha/api/siteverify', 42 | null, 43 | { 44 | params: { 45 | secret: secretKey, 46 | response: token 47 | } 48 | } 49 | ); 50 | 51 | return response.data.success === true; 52 | } catch (error) { 53 | console.error('reCAPTCHA verification error:', error); 54 | return false; 55 | } 56 | } 57 | 58 | module.exports = { verifyRecaptcha }; -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | # Add build arguments 4 | ARG CACHEBUST=1 5 | ARG BUILD_DATE 6 | ARG VCS_REF 7 | ARG VERSION 8 | 9 | # Add labels for GitHub Container Registry 10 | LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak" 11 | LABEL org.opencontainers.image.description="PicPeak Backend Service" 12 | LABEL org.opencontainers.image.licenses="MIT" 13 | 14 | # Upgrade npm to fix glob CVE-2025-64756 vulnerability 15 | RUN npm install -g npm@latest 16 | 17 | WORKDIR /app 18 | 19 | # Copy package files 20 | COPY package*.json ./ 21 | 22 | # Install dependencies (--omit=dev replaces deprecated --only=production) 23 | RUN npm ci --omit=dev 24 | 25 | # Copy application files 26 | COPY . . 27 | 28 | # Production stage 29 | FROM node:20-alpine 30 | 31 | WORKDIR /app 32 | 33 | # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) 34 | RUN apk upgrade --no-cache 35 | 36 | # Upgrade npm to fix glob CVE-2025-64756 vulnerability 37 | RUN npm install -g npm@latest 38 | 39 | # Install dumb-init for proper signal handling and postgresql-client for database checks 40 | RUN apk add --no-cache dumb-init postgresql-client 41 | 42 | # Create non-root user 43 | RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 44 | 45 | # Copy from builder 46 | COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules 47 | COPY --chown=nodejs:nodejs . . 48 | 49 | # Make wait script executable 50 | RUN chmod +x wait-for-db.sh 51 | 52 | # Create necessary directories 53 | RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \ 54 | chown -R nodejs:nodejs storage data logs 55 | 56 | USER nodejs 57 | 58 | EXPOSE 3000 59 | 60 | ENTRYPOINT ["dumb-init", "--"] 61 | CMD ["./wait-for-db.sh", "node", "server.js"] 62 | -------------------------------------------------------------------------------- /backend/src/utils/shareLinkUtils.js: -------------------------------------------------------------------------------- 1 | const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/; 2 | 3 | /** 4 | * Extracts the share token portion from a stored share link. 5 | * Supports full URLs, absolute paths, and legacy slug/token formats. 6 | * @param {string|null|undefined} shareLink 7 | * @returns {string|null} 8 | */ 9 | function extractShareToken(shareLink) { 10 | if (!shareLink) { 11 | return null; 12 | } 13 | 14 | const trimmed = String(shareLink).trim(); 15 | if (!trimmed) { 16 | return null; 17 | } 18 | 19 | // Remove protocol + host when a full URL is stored 20 | const path = trimmed.replace(/^https?:\/\/[^/]+/i, ''); 21 | const segments = path.split('/').filter(Boolean); 22 | if (segments.length === 0) { 23 | return null; 24 | } 25 | 26 | const candidate = segments[segments.length - 1]; 27 | return candidate || null; 28 | } 29 | 30 | /** 31 | * Returns true if the provided identifier looks like a generated share token. 32 | * @param {string|null|undefined} identifier 33 | * @returns {boolean} 34 | */ 35 | function isPotentialShareToken(identifier) { 36 | if (!identifier) { 37 | return false; 38 | } 39 | return SHARE_TOKEN_REGEX.test(String(identifier).trim()); 40 | } 41 | 42 | /** 43 | * Builds the gallery share path depending on whether short URLs are enabled. 44 | * @param {string} slug 45 | * @param {string} shareToken 46 | * @param {boolean} useShort 47 | * @returns {string} 48 | */ 49 | function buildSharePath(slug, shareToken, useShort) { 50 | if (!shareToken) { 51 | throw new Error('shareToken is required to build share path'); 52 | } 53 | if (useShort || !slug) { 54 | return `/gallery/${shareToken}`; 55 | } 56 | return `/gallery/${slug}/${shareToken}`; 57 | } 58 | 59 | module.exports = { 60 | extractShareToken, 61 | isPotentialShareToken, 62 | buildSharePath 63 | }; 64 | -------------------------------------------------------------------------------- /backend/migrations/core/042_backfill_event_upload_columns.js: -------------------------------------------------------------------------------- 1 | const logger = require('../../src/utils/logger'); 2 | 3 | async function ensureColumn(knex, tableName, columnName, alterFn) { 4 | const exists = await knex.schema.hasColumn(tableName, columnName); 5 | if (!exists) { 6 | logger.info(`Adding column ${tableName}.${columnName}`); 7 | await knex.schema.table(tableName, alterFn); 8 | } 9 | } 10 | 11 | exports.up = async function(knex) { 12 | await ensureColumn(knex, 'events', 'host_name', (table) => { 13 | table.string('host_name'); 14 | }); 15 | 16 | await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => { 17 | table.boolean('allow_user_uploads').defaultTo(false); 18 | }); 19 | 20 | await ensureColumn(knex, 'events', 'upload_category_id', (table) => { 21 | table.integer('upload_category_id'); 22 | }); 23 | 24 | await ensureColumn(knex, 'events', 'allow_downloads', (table) => { 25 | table.boolean('allow_downloads').defaultTo(true); 26 | }); 27 | 28 | await ensureColumn(knex, 'events', 'disable_right_click', (table) => { 29 | table.boolean('disable_right_click').defaultTo(false); 30 | }); 31 | 32 | await ensureColumn(knex, 'events', 'watermark_downloads', (table) => { 33 | table.boolean('watermark_downloads').defaultTo(false); 34 | }); 35 | 36 | await ensureColumn(knex, 'events', 'watermark_text', (table) => { 37 | table.text('watermark_text'); 38 | }); 39 | 40 | await ensureColumn(knex, 'events', 'hero_photo_id', (table) => { 41 | table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); 42 | }); 43 | 44 | await ensureColumn(knex, 'photos', 'uploaded_by', (table) => { 45 | table.string('uploaded_by').defaultTo('admin'); 46 | }); 47 | }; 48 | 49 | exports.down = async function() { 50 | // Non destructive migration; no rollback 51 | }; 52 | -------------------------------------------------------------------------------- /backend/src/middleware/secureStatic.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const { safePathJoin, isPathSafe } = require('../utils/fileSecurityUtils'); 4 | 5 | /** 6 | * Create a secure static file serving middleware that prevents path traversal attacks 7 | * @param {string} basePath - The base directory to serve files from 8 | * @param {Object} options - Express static options 9 | * @returns {Function} - Express middleware 10 | */ 11 | function secureStatic(basePath, options = {}) { 12 | const normalizedBase = path.resolve(basePath); 13 | 14 | return (req, res, next) => { 15 | // Get the requested file path - remove leading slash for validation 16 | const requestedPath = req.path.startsWith('/') ? req.path.substring(1) : req.path; 17 | 18 | // Validate the path doesn't contain dangerous patterns 19 | if (!isPathSafe(requestedPath)) { 20 | console.warn(`Potential path traversal attempt blocked: ${requestedPath}`); 21 | return res.status(403).json({ error: 'Access denied' }); 22 | } 23 | 24 | try { 25 | // Validate the full path is within the base directory 26 | const fullPath = safePathJoin(normalizedBase, requestedPath); 27 | 28 | // If validation passes, use express.static 29 | const staticMiddleware = express.static(normalizedBase, { 30 | ...options, 31 | // Disable directory listing for security 32 | index: false, 33 | // Don't allow dotfiles 34 | dotfiles: 'deny' 35 | }); 36 | 37 | return staticMiddleware(req, res, next); 38 | } catch (error) { 39 | // Path traversal detected 40 | console.error(`Path traversal blocked: ${requestedPath}`, error.message); 41 | return res.status(403).json({ error: 'Access denied' }); 42 | } 43 | }; 44 | } 45 | 46 | module.exports = secureStatic; -------------------------------------------------------------------------------- /frontend/src/components/admin/PhotoUploadModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { X } from 'lucide-react'; 3 | import { Button } from '../common'; 4 | import { PhotoUpload } from './PhotoUpload'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | interface PhotoUploadModalProps { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | eventId: number; 11 | onUploadComplete?: () => void; 12 | } 13 | 14 | export const PhotoUploadModal: React.FC = ({ 15 | isOpen, 16 | onClose, 17 | eventId, 18 | onUploadComplete 19 | }) => { 20 | const { t } = useTranslation(); 21 | 22 | if (!isOpen) return null; 23 | 24 | const handleUploadComplete = () => { 25 | if (onUploadComplete) { 26 | onUploadComplete(); 27 | } 28 | onClose(); 29 | }; 30 | 31 | return ( 32 |
33 |
34 | {/* Fixed Header */} 35 |
36 |

{t('upload.uploadMedia', t('events.uploadPhotos'))}

37 | 45 |
46 | 47 | {/* Scrollable Content */} 48 |
49 | 53 |
54 |
55 |
56 | ); 57 | }; 58 | 59 | PhotoUploadModal.displayName = 'PhotoUploadModal'; 60 | -------------------------------------------------------------------------------- /backend/migrations/legacy/027_add_rate_limit_settings_duplicate.js: -------------------------------------------------------------------------------- 1 | exports.up = async function(knex) { 2 | // Add rate limit settings to app_settings 3 | const rateLimitSettings = [ 4 | { 5 | setting_key: 'rate_limit_enabled', 6 | setting_value: JSON.stringify(true), 7 | setting_type: 'security' 8 | }, 9 | { 10 | setting_key: 'rate_limit_window_minutes', 11 | setting_value: JSON.stringify(15), 12 | setting_type: 'security' 13 | }, 14 | { 15 | setting_key: 'rate_limit_max_requests', 16 | setting_value: JSON.stringify(1000), 17 | setting_type: 'security' 18 | }, 19 | { 20 | setting_key: 'rate_limit_auth_max_requests', 21 | setting_value: JSON.stringify(5), 22 | setting_type: 'security' 23 | }, 24 | { 25 | setting_key: 'rate_limit_skip_authenticated', 26 | setting_value: JSON.stringify(true), 27 | setting_type: 'security' 28 | }, 29 | { 30 | setting_key: 'rate_limit_public_endpoints_only', 31 | setting_value: JSON.stringify(false), 32 | setting_type: 'security' 33 | } 34 | ]; 35 | 36 | // Insert settings if they don't exist 37 | for (const setting of rateLimitSettings) { 38 | const exists = await knex('app_settings') 39 | .where('setting_key', setting.setting_key) 40 | .first(); 41 | 42 | if (!exists) { 43 | await knex('app_settings').insert({ 44 | ...setting 45 | }); 46 | } 47 | } 48 | }; 49 | 50 | exports.down = async function(knex) { 51 | // Remove rate limit settings 52 | await knex('app_settings') 53 | .whereIn('setting_key', [ 54 | 'rate_limit_enabled', 55 | 'rate_limit_window_minutes', 56 | 'rate_limit_max_requests', 57 | 'rate_limit_auth_max_requests', 58 | 'rate_limit_skip_authenticated', 59 | 'rate_limit_public_endpoints_only' 60 | ]) 61 | .del(); 62 | }; -------------------------------------------------------------------------------- /frontend/src/components/gallery/ExpirationBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AlertTriangle, Download } from 'lucide-react'; 3 | import Countdown from 'react-countdown'; 4 | import { parseISO } from 'date-fns'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | interface ExpirationBannerProps { 8 | daysRemaining: number; 9 | expiresAt: string; 10 | } 11 | 12 | export const ExpirationBanner: React.FC = ({ 13 | daysRemaining, 14 | expiresAt 15 | }) => { 16 | const { t } = useTranslation(); 17 | const expirationDate = parseISO(expiresAt); 18 | 19 | const countdownRenderer = ({ days, hours, minutes, completed }: any) => { 20 | if (completed) { 21 | return {t('gallery.expired')}; 22 | } else { 23 | return ( 24 | 25 | {days}d {hours}h {minutes}m 26 | 27 | ); 28 | } 29 | }; 30 | 31 | const getBannerColor = () => { 32 | if (daysRemaining <= 1) return 'bg-red-600'; 33 | if (daysRemaining <= 3) return 'bg-amber-600'; 34 | return 'bg-amber-500'; 35 | }; 36 | 37 | return ( 38 |
39 |
40 |
41 |
42 | 43 | 44 | {t('gallery.expiresIn', { count: daysRemaining })} 45 | 46 |
47 |
48 | 49 | {t('gallery.downloadBefore')} 50 |
51 |
52 |
53 |
54 | ); 55 | }; -------------------------------------------------------------------------------- /frontend/src/components/common/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader2 } from 'lucide-react'; 3 | import { clsx } from 'clsx'; 4 | 5 | interface LoadingProps { 6 | size?: 'sm' | 'md' | 'lg'; 7 | text?: string; 8 | fullScreen?: boolean; 9 | className?: string; 10 | } 11 | 12 | export const Loading: React.FC = ({ 13 | size = 'md', 14 | text, 15 | fullScreen = false, 16 | className, 17 | }) => { 18 | const sizeStyles = { 19 | sm: 'h-4 w-4', 20 | md: 'h-8 w-8', 21 | lg: 'h-12 w-12', 22 | }; 23 | 24 | const content = ( 25 |
26 | 27 | {text && ( 28 |

{text}

29 | )} 30 |
31 | ); 32 | 33 | if (fullScreen) { 34 | return ( 35 |
36 | {content} 37 |
38 | ); 39 | } 40 | 41 | return content; 42 | }; 43 | 44 | interface LoadingSkeletonProps { 45 | className?: string; 46 | count?: number; 47 | type?: 'text' | 'card' | 'image'; 48 | } 49 | 50 | export const LoadingSkeleton: React.FC = ({ 51 | className, 52 | count = 1, 53 | type = 'text', 54 | }) => { 55 | const baseStyles = 'skeleton'; 56 | 57 | const typeStyles = { 58 | text: 'h-4 w-full rounded', 59 | card: 'h-32 w-full rounded-xl', 60 | image: 'aspect-square w-full rounded-lg', 61 | }; 62 | 63 | return ( 64 | <> 65 | {Array.from({ length: count }).map((_, index) => ( 66 |
74 | ))} 75 | 76 | ); 77 | }; -------------------------------------------------------------------------------- /tests/e2e/admin-create-event-ui.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; 4 | const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; 5 | 6 | function randomSuffix() { 7 | return Math.random().toString(36).slice(2, 8); 8 | } 9 | 10 | test('admin can create event via UI', async ({ page }) => { 11 | const eventName = `UI Playwright ${randomSuffix()}`; 12 | const hostEmail = `host+${randomSuffix()}@example.com`; 13 | 14 | // Login 15 | await page.goto('/admin/login'); 16 | await page.getByLabel(/Email/i).fill(ADMIN_EMAIL); 17 | await page.getByLabel(/Password/i).fill(ADMIN_PASSWORD); 18 | await page.getByRole('button', { name: /Sign In|Log in/i }).click(); 19 | await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 }); 20 | 21 | // Navigate to create event page 22 | const createButton = page.getByRole('button', { name: /Create Event/i }); 23 | if (await createButton.count()) { 24 | await createButton.first().click(); 25 | } else { 26 | await page.goto('/admin/events/new'); 27 | } 28 | 29 | await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 }); 30 | 31 | await page.getByLabel(/Event Name/i).fill(eventName); 32 | await page.getByLabel(/Customer Name/i).fill('Host User'); 33 | await page.getByLabel(/Event Date/i).fill('2025-12-31'); 34 | await page.getByLabel(/Customer Email/i).fill(hostEmail); 35 | await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL); 36 | await page.getByLabel(/Gallery Password/i).fill('UiPlay123!'); 37 | await page.getByLabel(/Confirm Password/i).fill('UiPlay123!'); 38 | 39 | await page.getByRole('button', { name: /Create Event/i }).click(); 40 | 41 | await expect(page).toHaveURL(/\/admin\/events\//, { timeout: 20000 }); 42 | await expect(page.getByRole('heading', { name: eventName })).toBeVisible(); 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/components/admin/index.ts: -------------------------------------------------------------------------------- 1 | export { AdminLayout } from './AdminLayout'; 2 | export { AdminSidebar } from './AdminSidebar'; 3 | export { AdminHeader } from './AdminHeader'; 4 | export { ThemeCustomizer } from './ThemeCustomizer'; 5 | export { PasswordChangeModal } from './PasswordChangeModal'; 6 | export { AdminAuthWrapper } from './AdminAuthWrapper'; 7 | export { PhotoUpload } from './PhotoUpload'; 8 | export { CategoryManager } from './CategoryManager'; 9 | export { EventCategoryManager } from './EventCategoryManager'; 10 | export { CMSEditor } from './CMSEditor'; 11 | export { WelcomeMessageEditor } from './WelcomeMessageEditor'; 12 | export { BulkArchiveModal } from './BulkArchiveModal'; 13 | export { MaintenanceBanner } from './MaintenanceBanner'; 14 | export { EmailPreviewModal } from './EmailPreviewModal'; 15 | export { AdminPhotoGrid } from './AdminPhotoGrid'; 16 | export { AdminPhotoViewer } from './AdminPhotoViewer'; 17 | export { PhotoFilters } from './PhotoFilters'; 18 | export { PasswordResetModal } from './PasswordResetModal'; 19 | export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; 20 | export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; 21 | export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; 22 | export { ThemeDisplay } from './ThemeDisplay'; 23 | export { ThemeEditorModal } from './ThemeEditorModal'; 24 | export { HeroPhotoSelector } from './HeroPhotoSelector'; 25 | export { PhotoUploadModal } from './PhotoUploadModal'; 26 | export { GalleryPreview } from './GalleryPreview'; 27 | export { BackupDashboard } from './BackupDashboard'; 28 | export { BackupConfiguration } from './BackupConfiguration'; 29 | export { BackupHistory } from './BackupHistory'; 30 | export { RestoreWizard } from './RestoreWizard'; 31 | export { FeedbackSettings } from './FeedbackSettings'; 32 | export { FeedbackModerationPanel } from './FeedbackModerationPanel'; 33 | export { WordFilterManager } from './WordFilterManager'; 34 | -------------------------------------------------------------------------------- /frontend/src/components/gallery/DownloadProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Download, X } from 'lucide-react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface DownloadProgressProps { 6 | isDownloading: boolean; 7 | progress?: number; 8 | fileName?: string; 9 | onCancel?: () => void; 10 | } 11 | 12 | export const DownloadProgress: React.FC = ({ 13 | isDownloading, 14 | progress = 0, 15 | fileName, 16 | onCancel, 17 | }) => { 18 | const { t } = useTranslation(); 19 | 20 | if (!isDownloading) return null; 21 | 22 | return ( 23 |
24 |
25 |
26 | 27 |
28 |

{t('download.downloading')}

29 | {fileName && ( 30 |

{fileName}

31 | )} 32 |
33 |
34 | {onCancel && ( 35 | 41 | )} 42 |
43 | 44 |
45 |
49 |
50 | 51 | {progress > 0 && ( 52 |

{Math.round(progress)}{t('download.percentComplete')}

53 | )} 54 |
55 | ); 56 | }; -------------------------------------------------------------------------------- /frontend/src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { Loader2 } from 'lucide-react'; 4 | 5 | interface ButtonProps extends React.ButtonHTMLAttributes { 6 | variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; 7 | size?: 'sm' | 'md' | 'lg'; 8 | isLoading?: boolean; 9 | leftIcon?: React.ReactNode; 10 | rightIcon?: React.ReactNode; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const Button = React.forwardRef( 15 | ( 16 | { 17 | className, 18 | variant = 'primary', 19 | size = 'md', 20 | isLoading = false, 21 | disabled, 22 | leftIcon, 23 | rightIcon, 24 | children, 25 | ...props 26 | }, 27 | ref 28 | ) => { 29 | const baseStyles = 'btn'; 30 | 31 | const variants = { 32 | primary: 'btn-primary', 33 | secondary: 'btn-secondary', 34 | outline: 'btn-outline', 35 | ghost: 'bg-transparent hover:bg-neutral-100 text-neutral-700', 36 | }; 37 | 38 | const sizes = { 39 | sm: 'btn-sm', 40 | md: 'btn-md', 41 | lg: 'btn-lg', 42 | }; 43 | 44 | return ( 45 | 66 | ); 67 | } 68 | ); 69 | 70 | Button.displayName = 'Button'; -------------------------------------------------------------------------------- /backend/src/utils/filenameSanitizer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sanitize a string to be used as a filename component 3 | * @param {string} str - The string to sanitize 4 | * @param {number} maxLength - Maximum length of the sanitized string 5 | * @returns {string} - Sanitized string 6 | */ 7 | function sanitizeFilename(str, maxLength = 50) { 8 | if (!str) return 'unnamed'; 9 | 10 | // Convert to string and trim 11 | let sanitized = String(str).trim(); 12 | 13 | // Replace spaces with underscores 14 | sanitized = sanitized.replace(/\s+/g, '_'); 15 | 16 | // Remove special characters except hyphens, underscores, and dots 17 | sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, ''); 18 | 19 | // Remove multiple consecutive underscores or hyphens 20 | sanitized = sanitized.replace(/[_\-]{2,}/g, '_'); 21 | 22 | // Remove leading/trailing underscores or hyphens 23 | sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, ''); 24 | 25 | // Limit length 26 | if (sanitized.length > maxLength) { 27 | sanitized = sanitized.substring(0, maxLength); 28 | } 29 | 30 | // If empty after sanitization, use default 31 | if (!sanitized) { 32 | sanitized = 'unnamed'; 33 | } 34 | 35 | return sanitized; 36 | } 37 | 38 | /** 39 | * Generate a photo filename based on event name, category, and counter 40 | * @param {string} eventName - The event name 41 | * @param {string} categoryName - The category name 42 | * @param {number} counter - The photo counter 43 | * @param {string} extension - The file extension (including dot) 44 | * @returns {string} - Generated filename 45 | */ 46 | function generatePhotoFilename(eventName, categoryName, counter, extension) { 47 | const sanitizedEvent = sanitizeFilename(eventName, 30); 48 | const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20); 49 | const paddedCounter = String(counter).padStart(4, '0'); 50 | 51 | return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`; 52 | } 53 | 54 | module.exports = { 55 | sanitizeFilename, 56 | generatePhotoFilename 57 | }; -------------------------------------------------------------------------------- /backend/migrations/core/041_add_external_media.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Migration 041: Add external media reference support 3 | * - events.source_mode: 'managed' | 'reference' 4 | * - events.external_path: relative path under external media root 5 | * - photos.source_origin: 'managed' | 'external' 6 | * - photos.external_relpath: relative path within event.external_path 7 | */ 8 | 9 | const { addColumnIfNotExists } = require('../helpers'); 10 | 11 | exports.up = async function(knex) { 12 | console.log('Running migration: 041_add_external_media'); 13 | 14 | // events.source_mode (default 'managed') 15 | await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => { 16 | table.string('source_mode').notNullable().defaultTo('managed'); 17 | }); 18 | 19 | // events.external_path (nullable) 20 | await addColumnIfNotExists(knex, 'events', 'external_path', (table) => { 21 | table.text('external_path'); 22 | }); 23 | 24 | // photos.source_origin (default 'managed') 25 | await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => { 26 | table.string('source_origin').notNullable().defaultTo('managed'); 27 | }); 28 | 29 | // photos.external_relpath (nullable) 30 | await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => { 31 | table.text('external_relpath'); 32 | }); 33 | 34 | // Helpful index for queries 35 | try { 36 | if (knex.client.config.client === 'pg') { 37 | await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)"); 38 | } else { 39 | await knex.schema.alterTable('photos', (table) => { 40 | table.index(['event_id', 'source_origin'], 'photos_event_source_idx'); 41 | }); 42 | } 43 | } catch (e) { 44 | console.log('Index creation skipped or failed (may already exist):', e.message); 45 | } 46 | 47 | console.log('Migration 041_add_external_media completed'); 48 | }; 49 | 50 | exports.down = async function(knex) { 51 | console.log('Rollback: 041_add_external_media'); 52 | // Keep columns (safe rollback not removing data). Intentionally no-op. 53 | }; 54 | 55 | -------------------------------------------------------------------------------- /frontend/src/components/admin/AdminAuthenticatedVideo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { api } from '../../config/api'; 3 | 4 | interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes { 5 | src: string; 6 | fallback?: React.ReactNode; 7 | } 8 | 9 | export const AdminAuthenticatedVideo: React.FC = ({ 10 | src, 11 | fallback, 12 | ...props 13 | }) => { 14 | const [videoSrc, setVideoSrc] = useState(null); 15 | const [loading, setLoading] = useState(true); 16 | const [error, setError] = useState(false); 17 | 18 | useEffect(() => { 19 | let cancelled = false; 20 | let objectUrl: string | null = null; 21 | 22 | const loadVideo = async () => { 23 | try { 24 | setLoading(true); 25 | setError(false); 26 | setVideoSrc(null); 27 | 28 | const response = await api.get(src, { responseType: 'blob' }); 29 | 30 | if (!cancelled) { 31 | objectUrl = URL.createObjectURL(response.data); 32 | setVideoSrc(objectUrl); 33 | setLoading(false); 34 | } 35 | } catch { 36 | if (!cancelled) { 37 | setError(true); 38 | setLoading(false); 39 | } 40 | } 41 | }; 42 | 43 | if (src) { 44 | loadVideo(); 45 | } 46 | 47 | return () => { 48 | cancelled = true; 49 | if (objectUrl) { 50 | URL.revokeObjectURL(objectUrl); 51 | } 52 | }; 53 | }, [src]); 54 | 55 | if (loading) { 56 | return
; 57 | } 58 | 59 | if (error || !videoSrc) { 60 | return fallback ? ( 61 | <>{fallback} 62 | ) : ( 63 |
64 | Failed to load 65 |
66 | ); 67 | } 68 | 69 | return ( 70 |