Compare commits

..

29 Commits

Author SHA1 Message Date
Gitea Actions Bot 954103510a chore: bump version to 1.0.41
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 15:14:40 +00:00
paul 801e1f81d9 fix: improve gallery lightbox UI for better user experience
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Removed photo filename display from lightbox for cleaner viewing
- Fixed z-index layering to ensure controls always appear above images
- Changed control elements from z-10 to z-20 for proper visibility
- Set image container to z-0 to prevent overlap with controls
- Ensures all buttons remain accessible regardless of image size or zoom

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 17:09:37 +02:00
paul f9861480aa fix: improve GitHub mirror workflow reliability
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m14s
- Added workflow_dispatch for manual triggering
- Added debugging steps to identify issues
- Clean up existing branches before creating new ones
- Remove existing remotes to avoid conflicts
- Added GitHub token verification
- Added completion status messages
- Made workflow more resilient with better error handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 16:30:22 +02:00
Gitea Actions Bot a26dfd3d6f chore: bump version to 1.0.40
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 14:21:15 +00:00
paul 1db908771f fix: resolve multiple UI issues in admin panel
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed dropdown menu visibility in events table by using fixed positioning
- Fixed double /api prefix in settings upload endpoints (favicon, watermark)
- Fixed thumbnail display in hero image selection by properly handling API paths
- Removed unnecessary console.log statements
- Added proper cleanup for dropdown on scroll/resize events

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 16:16:47 +02:00
Gitea Actions Bot 59651b8c24 chore: bump version to 1.0.39
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 11:04:23 +00:00
paul 7ccd48297f fix: resolve dropdown menu visibility issue in admin events table
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Changed Card container from overflow-hidden to overflow-visible
- Added overflow-y-visible to prevent vertical clipping
- Dropdown menu now properly displays even with few events
- Table only scrolls when content exceeds viewport height

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 12:59:54 +02:00
Gitea Actions Bot d05ff6380e chore: bump version to 1.0.38
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:51:55 +00:00
paul 605f773a7e fix: resolve gallery photo/thumbnail serving issues
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Change gallery photo URLs from static paths to API endpoints
- Add dedicated thumbnail serving endpoint for galleries
- Add test script to diagnose authentication issues
- Add nginx configuration documentation for Authorization header

This fixes the issue where photos and thumbnails work in admin but not
in gallery view. The problem was that static file routes with auth
middleware often have Authorization headers stripped by reverse proxies.
Using API endpoints ensures proper authentication handling.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:47:53 +02:00
Gitea Actions Bot c844f634c8 chore: bump version to 1.0.37
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:37:32 +00:00
paul 1d94398e2d fix: add thumbnail cleanup on archive deletion and diagnostic scripts
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Fix archive deletion to also clean up associated thumbnails
- Add cleanup-thumbnails.js script to remove temporary and orphaned thumbnails
- Add diagnose-thumbnails.js script to troubleshoot thumbnail serving issues
- Update README with documentation for new scripts

This prevents thumbnail accumulation when events are deleted and helps
diagnose why thumbnails might not be showing despite existing on disk.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:33:11 +02:00
Gitea Actions Bot a2551dc0ad chore: bump version to 1.0.36
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:27:09 +00:00
paul 32821934e6 feat: add storage management scripts for production troubleshooting
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 4s
- Add check-storage.js to verify storage structure and file existence
- Add regenerate-thumbnails.js to recreate missing thumbnail files
- Include comprehensive README with usage instructions
- Scripts help diagnose and fix missing photo/thumbnail issues

These scripts are essential for production maintenance when photos
or thumbnails are missing from the storage directory.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:23:00 +02:00
Gitea Actions Bot b9c28e52cd chore: bump version to 1.0.35
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 07:15:42 +00:00
paul c94b6268cf fix: resolve gallery authentication and redirect issues
Test and Lint / backend-test (push) Failing after 32s
Test and Lint / frontend-test (push) Successful in 2m15s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 4s
- Fix useWatermarkSettings hook to use public API endpoint instead of admin endpoint
- Add hero_photo_id to gallery authentication response
- Prevent 401 errors on gallery pages from redirecting to admin login
- Gallery pages now correctly fetch settings without requiring admin auth

The main issue was that gallery pages were calling admin-only endpoints
which triggered 401 errors and caused redirects to the admin login page.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 09:11:50 +02:00
Gitea Actions Bot 439c743fd1 chore: bump version to 1.0.34
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 06:53:59 +00:00
paul 74144f1fc6 fix: handle both share_link formats in gallery token verification
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Support both full URL and token-only formats in share_link column
- Fix gallery info endpoint to correctly validate share tokens
- Prevents "gallery not found" errors for valid share links

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:49:45 +02:00
Gitea Actions Bot 99a0376657 chore: bump version to 1.0.33
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 06:39:40 +00:00
paul 21b1e79672 fix: resolve gallery login redirect issue
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Updated API interceptor to better handle gallery authentication
- Fixed 401 error handling to prevent redirect loops on gallery pages
- Improved token extraction logic for gallery API requests
- Consolidated duplicate verifyGalleryAccess middleware
- Added proper error handling in GalleryView component
- Gallery authentication now properly distinguishes from admin routes

The issue was caused by the API interceptor redirecting to admin login
when gallery API calls failed with 401, even when users were already
on gallery pages attempting to authenticate.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:35:33 +02:00
Gitea Actions Bot cfaee103b6 chore: bump version to 1.0.32
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 06:21:46 +00:00
paul c0e346992d fix: improve photo authentication middleware for thumbnails
Test and Lint / backend-test (push) Successful in 1m14s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add eventId check from JWT token for thumbnail access
- Better error logging for debugging authentication issues
- Add admin debug endpoint to check photo existence
- More permissive thumbnail access when valid gallery token exists

This should help diagnose why photos are returning 404 errors in production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:17:52 +02:00
Gitea Actions Bot 04f45a16c9 chore: bump version to 1.0.31
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 21:15:34 +00:00
paul efad1da74d fix: resolve image and thumbnail loading issues
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Fix static file serving paths to use correct storage directory
- Remove /api prefix from admin photo URLs to prevent double /api/api/ issue
- Fix thumbnail URL generation in gallery to use correct path format
- Update storage path resolution to support both relative and absolute paths

The issues were:
1. Admin images had URLs like /api/api/admin/events/2/thumbnail/90
2. Gallery thumbnails were looking for /thumbnails/thumb_*.jpg but paths were wrong
3. Static serving middleware was using incorrect storage paths

All images and thumbnails should now load correctly in both admin and gallery views.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 23:11:25 +02:00
Gitea Actions Bot 0a2b010332 chore: bump version to 1.0.30
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 19:17:01 +00:00
paul 6906c8bcf7 feat: increase file upload limit from 20 to 500 with performance optimizations
Test and Lint / backend-test (push) Successful in 1m10s
Test and Lint / frontend-test (push) Successful in 2m22s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 3s
Backend changes:
- Update multer configuration to accept up to 500 files per upload
- Implement batch processing (10 files per transaction) for better performance
- Add memory-efficient Sharp configuration for thumbnail generation
- Increase Express body parser limits to handle large payloads
- Add proper error handling and reporting for partial upload failures

Frontend changes:
- Update validation to allow 500 files maximum
- Implement chunked uploads (50 files per chunk) to prevent timeouts
- Add progress tracking with chunk information display
- Update error messages and translations (EN/DE)

Performance optimizations:
- Disable Sharp cache to prevent memory buildup
- Limit Sharp concurrency to 2 operations
- Use sequential read for large images
- Process files in database transaction batches
- Return detailed upload results including success/failure counts

This implementation ensures the application can handle large photo uploads
efficiently without running into memory or timeout issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:12:35 +02:00
Gitea Actions Bot ac48bfdd0d chore: bump version to 1.0.29
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 19:09:25 +00:00
paul ec99243b6f test: add database compatibility tests
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add comprehensive tests for formatBoolean() function
- Test PostgreSQL vs SQLite boolean handling
- Test date formatting and manipulation functions
- Verify insertAndGetId handles both database result formats

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 9932621e14 fix: comprehensive boolean compatibility for PostgreSQL and SQLite
- Add formatBoolean() usage to all boolean database queries
- Fix 36 boolean comparisons across 18 backend files
- Ensure compatibility between SQLite (0/1) and PostgreSQL (true/false)
- Update all WHERE clauses and UPDATE operations with boolean values

This completes the database compatibility fixes for production deployment.
All boolean operations now work correctly with both database systems.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 0fb17c78fa fix: photo upload issues with file limit and date formatting
- Add 20-file limit validation to PhotoUpload component
- Prevent Multer "Unexpected field" errors by enforcing client-side limit
- Fix JSON parsing error in dateFormatter when value is already an object
- Add missing translation keys for upload error messages
- Handle both string and object values for date format settings

These fixes resolve the 400 error when uploading more than 20 files
and the "Unexpected token o in JSON" error during email queue creation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
52 changed files with 1388 additions and 252 deletions
+43 -3
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
@@ -19,11 +20,22 @@ jobs:
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Debug - Show current branch and status
run: |
echo "Current branch:"
git branch -a
echo "Git status:"
git status
echo "Remote info:"
git remote -v
- name: Create filtered branch
run: |
# Clean up any existing github-mirror branch
git branch -D github-mirror || true
# Create a new branch for GitHub
git checkout --orphan -b github-mirror
git checkout --orphan github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
@@ -43,17 +55,45 @@ jobs:
git rm -r --cached CLAUDE.md || true
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
git rm -r --cached logs/ || true
git rm -r --cached frontend/.claudedocs/ || true
git rm -r --cached test-maintenance.sh || true
git rm -r --cached storage/ || true
# Commit the changes
git commit -m "Remove sensitive files for GitHub mirror" || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
exit 1
else
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote added:"
git remote -v
# Force push the filtered branch to GitHub main
git push github github-mirror:main --force
echo "Pushing to GitHub..."
git push github github-mirror:main --force
echo "Push completed successfully!"
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
+34 -13
View File
@@ -1,13 +1,17 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🌟 Why Choose PicPeak?
@@ -124,17 +128,34 @@ Found a security issue? Please email security@example.com
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>View Gallery Examples</summary>
<summary>👆 Click to see more interface details</summary>
### Admin Dashboard
![Admin Dashboard](https://github.com/the-luap/picpeak/assets/placeholder-admin.png)
#### What makes PicPeak's interface special:
### Client Gallery View
![Gallery View](https://github.com/the-luap/picpeak/assets/placeholder-gallery.png)
### Mobile Experience
![Mobile View](https://github.com/the-luap/picpeak/assets/placeholder-mobile.png)
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
Binary file not shown.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.28",
"version": "1.0.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.28",
"version": "1.0.41",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.28",
"version": "1.0.41",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to check storage directory structure and verify files
* Usage: node scripts/check-storage.js [eventSlug]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
async function checkDirectory(dirPath, description) {
try {
await fs.access(dirPath);
const stats = await fs.stat(dirPath);
const files = await fs.readdir(dirPath);
console.log(`${description}: ${dirPath}`);
console.log(` - Files/Folders: ${files.length}`);
console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`);
return true;
} catch (error) {
console.log(`${description}: ${dirPath} - ${error.message}`);
return false;
}
}
async function checkStorageStructure(eventSlug = null) {
console.log('Checking storage structure...');
console.log(`Storage base path: ${STORAGE_PATH}\n`);
// Check main directories
await checkDirectory(STORAGE_PATH, 'Storage root');
await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory');
await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events');
await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events');
await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails');
await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads');
console.log('\n---\n');
// If event slug provided, check specific event
if (eventSlug) {
console.log(`Checking specific event: ${eventSlug}`);
const event = await db('events').where('slug', eventSlug).first();
if (!event) {
console.log(`✗ Event not found in database: ${eventSlug}`);
return;
}
console.log(`✓ Event found in database:`);
console.log(` - ID: ${event.id}`);
console.log(` - Name: ${event.event_name}`);
console.log(` - Active: ${event.is_active}`);
console.log(` - Archived: ${event.is_archived}`);
// Check event directory
const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug);
const eventExists = await checkDirectory(eventDir, 'Event directory');
if (eventExists) {
const files = await fs.readdir(eventDir);
console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`);
}
// Check photos in database
const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path');
console.log(`\nDatabase photos: ${photos.length}`);
// Check if photo files exist
let existingPhotos = 0;
let missingPhotos = 0;
let existingThumbnails = 0;
let missingThumbnails = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
try {
await fs.access(photoPath);
existingPhotos++;
} catch {
missingPhotos++;
console.log(` ✗ Missing photo: ${photo.path}`);
}
if (photo.thumbnail_path) {
const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, ''));
try {
await fs.access(thumbPath);
existingThumbnails++;
} catch {
missingThumbnails++;
console.log(` ✗ Missing thumbnail: ${photo.thumbnail_path}`);
}
}
}
console.log(`\nFile check summary:`);
console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`);
console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`);
} else {
// List all event directories
try {
const activeDir = path.join(STORAGE_PATH, 'events/active');
const eventDirs = await fs.readdir(activeDir);
console.log(`Active event directories: ${eventDirs.length}`);
for (const dir of eventDirs.slice(0, 10)) {
console.log(` - ${dir}`);
}
if (eventDirs.length > 10) {
console.log(` ... and ${eventDirs.length - 10} more`);
}
} catch (error) {
console.log('Could not list event directories:', error.message);
}
}
}
// Parse command line arguments
const eventSlug = process.argv[2] || null;
// Run the script
checkStorageStructure(eventSlug).then(async () => {
await db.destroy();
console.log('\nStorage check complete');
}).catch(async error => {
console.error('Error:', error);
await db.destroy();
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Script to clean up orphaned and temporary thumbnails
* Usage: node scripts/cleanup-thumbnails.js [--dry-run]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function cleanupThumbnails(dryRun = false) {
console.log('Starting thumbnail cleanup...');
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);
try {
// Get all thumbnail files
const files = await fs.readdir(THUMBNAILS_DIR);
console.log(`Found ${files.length} files in thumbnails directory`);
// Get all valid thumbnail paths from database
const validThumbnails = await db('photos')
.whereNotNull('thumbnail_path')
.select('thumbnail_path');
const validPaths = new Set(
validThumbnails.map(t => path.basename(t.thumbnail_path))
);
console.log(`Found ${validPaths.size} valid thumbnails in database\n`);
let tempCount = 0;
let orphanedCount = 0;
let validCount = 0;
let deletedCount = 0;
for (const file of files) {
// Skip directories
const filePath = path.join(THUMBNAILS_DIR, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) continue;
// Check if it's a temporary file
if (file.startsWith('thumb_temp_')) {
tempCount++;
console.log(`Temporary file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
}
// Check if it's an orphaned thumbnail
else if (!validPaths.has(file)) {
orphanedCount++;
console.log(`Orphaned file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
} else {
validCount++;
}
}
console.log('\n--- Summary ---');
console.log(`Total files: ${files.length}`);
console.log(`Valid thumbnails: ${validCount}`);
console.log(`Temporary files: ${tempCount}`);
console.log(`Orphaned files: ${orphanedCount}`);
if (!dryRun) {
console.log(`Deleted files: ${deletedCount}`);
} else {
console.log(`Files to be deleted: ${tempCount + orphanedCount}`);
}
} catch (error) {
console.error('Error during cleanup:', error);
process.exit(1);
}
}
// Parse command line arguments
const dryRun = process.argv.includes('--dry-run');
// Run the cleanup
cleanupThumbnails(dryRun).then(async () => {
await db.destroy();
console.log('\nCleanup complete');
}).catch(async error => {
console.error('Cleanup failed:', error);
await db.destroy();
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to diagnose thumbnail serving issues
* Usage: node scripts/diagnose-thumbnails.js <eventId>
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function diagnoseThumbnails(eventId) {
if (!eventId) {
console.error('Usage: node scripts/diagnose-thumbnails.js <eventId>');
process.exit(1);
}
console.log(`Diagnosing thumbnails for event ID: ${eventId}`);
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`);
try {
// Get event info
const event = await db('events').where('id', eventId).first();
if (!event) {
console.error(`Event not found with ID: ${eventId}`);
return;
}
console.log(`Event: ${event.event_name} (${event.slug})`);
console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`);
// Get photos for this event
const photos = await db('photos')
.where('event_id', eventId)
.select('id', 'filename', 'path', 'thumbnail_path');
console.log(`Found ${photos.length} photos in database\n`);
let missingThumbnails = 0;
let existingThumbnails = 0;
let pathIssues = [];
for (const photo of photos.slice(0, 10)) { // Check first 10 photos
console.log(`Photo ID ${photo.id}: ${photo.filename}`);
console.log(` Photo path: ${photo.path}`);
console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`);
if (photo.thumbnail_path) {
// Expected thumbnail filename
const expectedThumbName = `thumb_${photo.filename}`;
const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName);
// Check if thumbnail exists
try {
await fs.access(expectedThumbPath);
console.log(` ✓ Thumbnail exists at: ${expectedThumbName}`);
existingThumbnails++;
// Check if DB path matches expected path
const dbThumbName = path.basename(photo.thumbnail_path);
if (dbThumbName !== expectedThumbName) {
console.log(` ⚠ Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`);
pathIssues.push({
photoId: photo.id,
dbPath: photo.thumbnail_path,
expectedPath: `thumbnails/${expectedThumbName}`
});
}
} catch {
console.log(` ✗ Thumbnail missing: ${expectedThumbName}`);
missingThumbnails++;
}
} else {
console.log(` ✗ No thumbnail path in database`);
missingThumbnails++;
}
console.log('');
}
console.log('--- Summary ---');
console.log(`Existing thumbnails: ${existingThumbnails}`);
console.log(`Missing thumbnails: ${missingThumbnails}`);
console.log(`Path issues: ${pathIssues.length}`);
if (pathIssues.length > 0) {
console.log('\n--- Path Issues ---');
console.log('The following photos have incorrect thumbnail paths in the database:');
for (const issue of pathIssues) {
console.log(`Photo ID ${issue.photoId}:`);
console.log(` Current: ${issue.dbPath}`);
console.log(` Should be: ${issue.expectedPath}`);
}
console.log('\nTo fix path issues, run:');
console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`);
}
// Check for any thumbnails in the directory that match this event
const files = await fs.readdir(THUMBNAILS_DIR);
const eventThumbnails = files.filter(f => {
// Try to match thumbnails for this event
for (const photo of photos) {
if (f === `thumb_${photo.filename}`) return true;
}
return false;
});
console.log(`\n--- Filesystem Check ---`);
console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`);
} catch (error) {
console.error('Error during diagnosis:', error);
process.exit(1);
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the diagnosis
diagnoseThumbnails(eventId).then(async () => {
await db.destroy();
console.log('\nDiagnosis complete');
}).catch(async error => {
console.error('Diagnosis failed:', error);
await db.destroy();
process.exit(1);
});
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Script to regenerate missing thumbnails for photos in the database
* Usage: node scripts/regenerate-thumbnails.js [eventId]
*/
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../src/database/db');
// Configuration
const THUMBNAIL_SIZE = 300;
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function ensureDirectoryExists(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
console.log(`Created directory: ${dirPath}`);
}
}
async function generateThumbnail(photoPath, thumbnailPath) {
try {
await sharp(photoPath)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return true;
} catch (error) {
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
return false;
}
}
async function regenerateThumbnails(eventId = null) {
try {
console.log('Starting thumbnail regeneration...');
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
// Ensure thumbnails directory exists
await ensureDirectoryExists(THUMBNAILS_DIR);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.select(
'photos.id',
'photos.filename',
'photos.path',
'photos.thumbnail_path',
'events.slug as event_slug'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
const thumbnailFilename = `thumb_${photo.filename}`;
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
try {
// Check if photo file exists
await fs.access(photoPath);
// Check if thumbnail already exists
try {
await fs.access(thumbnailPath);
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
skipCount++;
continue;
} catch {
// Thumbnail doesn't exist, generate it
}
console.log(`Generating thumbnail for ${photo.filename}...`);
const success = await generateThumbnail(photoPath, thumbnailPath);
if (success) {
// Update database with thumbnail path
await db('photos')
.where('id', photo.id)
.update({
thumbnail_path: `thumbnails/${thumbnailFilename}`
});
successCount++;
console.log(`✓ Generated thumbnail for ${photo.filename}`);
} else {
errorCount++;
}
} catch (error) {
console.error(`✗ Photo file not found: ${photoPath}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Successfully generated: ${successCount}`);
console.log(`- Skipped (already exist): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
console.log(`- Total processed: ${photos.length}`);
} catch (error) {
console.error('Error during thumbnail regeneration:', error);
process.exit(1);
} finally {
await db.destroy();
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the script
regenerateThumbnails(eventId).then(() => {
console.log('Script completed successfully');
process.exit(0);
}).catch(error => {
console.error('Script failed:', error);
process.exit(1);
});
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Script to test photo authentication
* Usage: node scripts/test-photo-auth.js <jwt-token>
*/
const axios = require('axios');
async function testPhotoAuth(token) {
if (!token) {
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
process.exit(1);
}
const baseUrl = process.env.API_URL || 'http://localhost:3001';
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
console.log(`Base URL: ${baseUrl}\n`);
// Test URLs
const tests = [
{
name: 'Thumbnail via static route',
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Photo via static route',
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Gallery photos API',
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
headers: { 'Authorization': `Bearer ${token}` }
}
];
for (const test of tests) {
console.log(`Testing: ${test.name}`);
console.log(`URL: ${test.url}`);
try {
const response = await axios.get(test.url, {
headers: test.headers,
validateStatus: () => true // Don't throw on any status
});
console.log(`Status: ${response.status}`);
console.log(`Headers:`, response.headers['content-type']);
if (response.status === 200) {
if (test.name.includes('API')) {
console.log(`Photos count: ${response.data.photos?.length || 0}`);
} else {
console.log(`Content length: ${response.headers['content-length']} bytes`);
}
} else {
console.log(`Error:`, response.data);
}
} catch (error) {
console.log(`Network error:`, error.message);
}
console.log('---\n');
}
// Decode token to show info
try {
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
console.log('Token payload:', payload);
} catch (error) {
console.log('Failed to decode token');
}
}
// Get token from command line
const token = process.argv[2];
testPhotoAuth(token).catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+9 -6
View File
@@ -125,9 +125,9 @@ const authLimiter = rateLimit({
app.use('/api/', limiter);
app.use('/api/auth', authLimiter);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -146,14 +146,17 @@ const setCorsHeaders = (req, res, next) => {
// Import secure static middleware
const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/events/active')));
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/thumbnails')));
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Health check endpoint
app.get('/health', async (req, res) => {
+83
View File
@@ -0,0 +1,83 @@
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
describe('Database Compatibility', () => {
// Save original env
const originalEnv = process.env.DATABASE_CLIENT;
afterEach(() => {
// Restore original env after each test
if (originalEnv) {
process.env.DATABASE_CLIENT = originalEnv;
} else {
delete process.env.DATABASE_CLIENT;
}
});
describe('formatBoolean', () => {
test('should format boolean values correctly', () => {
// Mock for SQLite
process.env.DATABASE_CLIENT = 'sqlite3';
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
// Mock for PostgreSQL
process.env.DATABASE_CLIENT = 'pg';
expect(formatBoolean(true)).toBe(true);
expect(formatBoolean(false)).toBe(false);
// Default (no env var) should be SQLite
delete process.env.DATABASE_CLIENT;
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
});
});
describe('isPostgreSQL', () => {
test('should detect PostgreSQL correctly', () => {
process.env.DATABASE_CLIENT = 'pg';
expect(isPostgreSQL()).toBe(true);
process.env.DATABASE_CLIENT = 'sqlite3';
expect(isPostgreSQL()).toBe(false);
delete process.env.DATABASE_CLIENT;
expect(isPostgreSQL()).toBe(false); // Default to SQLite
});
});
describe('formatDateForDB', () => {
test('should format dates as ISO strings', () => {
const date = new Date('2024-01-15T10:30:00Z');
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('addDays', () => {
test('should add days correctly', () => {
const date = new Date('2024-01-15');
const result = addDays(date, 30);
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
const negativeResult = addDays(date, -7);
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
});
});
describe('insertAndGetId', () => {
test('should handle PostgreSQL result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([{ id: 123 }])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(123);
});
test('should handle SQLite result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([456])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(456);
});
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+3 -2
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
/**
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function adminAuth(req, res, next) {
try {
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first();
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
+8 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
@@ -10,7 +11,13 @@ async function verifyGalleryAccess(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
+27 -15
View File
@@ -1,12 +1,15 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
@@ -25,9 +28,22 @@ async function photoAuth(req, res, next) {
// Check if it's a gallery token
if (decoded.type === 'gallery') {
// For thumbnails, we accept any valid gallery token
// For thumbnails, we need to verify the token is for a valid event
if (!eventSlug) {
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
// Extract event ID from the decoded token
if (decoded.eventId) {
const event = await db('events')
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
}
}
// Fallback to slug
const event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -35,7 +51,9 @@ async function photoAuth(req, res, next) {
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events')
.where({ slug: eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -45,18 +63,12 @@ async function photoAuth(req, res, next) {
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
if (!eventSlug) {
// For thumbnails with admin token, allow access
return next();
}
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
return next();
}
// For both thumbnails and photos with admin token, allow access
return next();
}
} catch (err) {
// Token invalid, fall through to password check
console.error('JWT verification failed:', err.message);
}
}
@@ -67,12 +79,12 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), we require JWT token
if (!eventSlug) {
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
+25 -7
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
// Get total count
const totalCount = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
db.raw('SUM(photos.size_bytes) as total_size')
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', true)
.where('events.is_archived', formatBoolean(true))
.groupBy('events.id')
.orderBy('events.archived_at', 'desc')
.limit(limit)
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -303,7 +304,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -352,7 +353,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -362,12 +363,29 @@ router.delete('/:id', adminAuth, async (req, res) => {
// Delete archive file if exists
if (archive.archive_path) {
try {
await fs.unlink(archive.archive_path);
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
await fs.unlink(fullArchivePath);
} catch (error) {
console.error('Failed to delete archive file:', error);
}
}
// Delete thumbnails for this event
const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
for (const photo of photos) {
if (photo.thumbnail_path) {
try {
const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, ''));
await fs.unlink(thumbPath);
} catch (error) {
// Ignore errors - thumbnail might already be deleted
}
}
}
// Delete from database (cascade will delete photos and logs)
await db('events').where('id', req.params.id).delete();
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const router = express.Router();
@@ -8,7 +9,7 @@ const router = express.Router();
router.get('/global', adminAuth, async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', true)
.where('is_global', formatBoolean(true))
.orderBy('name', 'asc');
res.json(categories);
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', true);
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
+143 -72
View File
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
});
// Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => {
upload.array('photos', 500)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
@@ -136,90 +143,122 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
}
const uploadedPhotos = [];
const errors = [];
// Process each uploaded file
for (const file of req.files) {
let trx;
// Process files in batches to optimize database operations
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
const batch = req.files.slice(i, i + BATCH_SIZE);
// Start a single transaction for the batch
const trx = await db.transaction();
try {
// Start transaction for atomic counter update
trx = await db.transaction();
// Get and increment the counter for this category
let counter = 1;
// Get initial counter for this batch
let batchCounter = 1;
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: parsedCategoryId })
.forUpdate()
.first();
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
batchCounter = (categoryData.photo_counter || 0) + 1;
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
batchCounter = (uncategorizedCount.count || 0) + 1;
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
const batchPhotos = [];
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
try {
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Update file object
file.filename = newFilename;
file.path = newPath;
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath;
// Prepare photo data for batch insert
batchPhotos.push({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual',
size_bytes: file.size
});
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
// Delete the file if it was partially processed
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
// Update file object
file.filename = newFilename;
file.path = newPath;
// Batch insert all photos from this batch
if (batchPhotos.length > 0) {
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
// Update category counter if needed
if (category) {
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
}
// Add to uploaded photos array
batchPhotos.forEach((photo, index) => {
uploadedPhotos.push({
id: insertedIds[index]?.id || insertedIds[index],
filename: photo.filename,
size: photo.size_bytes,
category_id: photo.category_id
});
});
}
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction
// Commit the batch transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
category_id: parsedCategoryId || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
if (trx) await trx.rollback();
// Continue with other files
console.error(`Error processing batch starting at index ${i}:`, error);
await trx.rollback();
// Try to clean up files from failed batch
for (const file of batch) {
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
}
@@ -230,10 +269,22 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
// Prepare response
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos
});
photos: uploadedPhotos,
totalFiles: req.files.length,
successCount: uploadedPhotos.length,
failureCount: errors.length
};
// Include error details if any files failed
if (errors.length > 0) {
response.errors = errors;
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
}
res.json(response);
} catch (error) {
console.error('Error uploading photos:', error);
res.status(500).json({ error: 'Failed to upload photos' });
@@ -501,8 +552,8 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
url: `/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -595,4 +646,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
}
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
const photos = await db('photos').where({ event_id: eventId }).limit(5);
res.json({
event: event || 'Not found',
photoCount: photoCount.count,
samplePhotos: photos,
storagePath: getStoragePath()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+2 -1
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const router = express.Router();
@@ -513,7 +514,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
// Get archive storage
const archives = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.whereNotNull('archive_path')
.select('archive_path');
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+4 -2
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const router = express.Router();
@@ -76,7 +77,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
@@ -118,7 +119,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id
upload_category_id: event.upload_category_id,
hero_photo_id: event.hero_photo_id
}
});
} catch (error) {
+5 -4
View File
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
@@ -113,9 +114,9 @@ router.get('/', adminAuth, async (req, res) => {
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', true);
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', true);
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
@@ -162,7 +163,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('events').where('id', id).update({ is_active: false });
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true });
} catch (error) {
@@ -188,7 +189,7 @@ router.post('/:id/extend', adminAuth, [
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: true // Reactivate if expired
is_active: formatBoolean(true) // Reactivate if expired
});
res.json({ expires_at: newExpiration });
+47 -30
View File
@@ -1,46 +1,23 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events')
.where({ id: decoded.eventId, is_active: true, is_archived: false })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = event;
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: true, is_archived: false })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.first();
@@ -83,7 +60,11 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
const expectedToken = event.share_link.split('/').pop();
let expectedToken = event.share_link;
// Handle both formats: full URL or just token
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
@@ -121,7 +102,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Get all categories for this event
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
this.where('is_global', formatBoolean(true))
.orWhere('event_id', req.event.id);
})
.orderBy('is_global', 'desc')
@@ -155,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -339,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
}
});
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Set appropriate headers
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Send file
res.sendFile(path.resolve(thumbPath));
} catch (error) {
console.error('Error serving thumbnail:', error);
res.status(500).json({ error: 'Failed to serve thumbnail' });
}
});
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
try {
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage');
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
// Get event
const event = await db('events')
.where({ slug })
.where('is_active', true)
.where('is_active', formatBoolean(true))
.first();
if (!event) {
+1 -1
View File
@@ -75,7 +75,7 @@ async function queueExpirationWarning(event) {
async function handleExpiredEvent(event) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: false });
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
+2 -1
View File
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const logger = require('../utils/logger');
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Find the event
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return;
// Get file stats
+26 -9
View File
@@ -2,6 +2,10 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
const THUMBNAIL_WIDTH = 300;
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
// Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
try {
// Generate thumbnail with memory-efficient settings
await sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true // More memory efficient for large images
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
console.error(`Failed to generate thumbnail for ${filename}:`, error);
// Return null if thumbnail generation fails, don't fail the whole upload
return null;
}
}
module.exports = { generateThumbnail };
+3 -2
View File
@@ -4,6 +4,7 @@
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger');
// Configuration constants
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '<', cutoffTime.toISOString())
.delete();
} catch (error) {
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
+15 -1
View File
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
try {
// Get date format setting from database
const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT;
let dateConfig = DEFAULT_FORMAT;
if (setting && setting.setting_value) {
// Handle both string and object values
if (typeof setting.setting_value === 'string') {
try {
dateConfig = JSON.parse(setting.setting_value);
} catch (e) {
console.warn('Failed to parse date format setting:', e.message);
dateConfig = DEFAULT_FORMAT;
}
} else {
dateConfig = setting.setting_value;
}
}
const dateObj = date instanceof Date ? date : new Date(date);
View File
+59
View File
@@ -0,0 +1,59 @@
# Nginx Configuration Fix for Photo Authentication
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.
## Common Issue
The `Authorization` header is often not passed through by default in nginx proxy configurations.
## Fix
Add these lines to your nginx configuration for the PicPeak location block:
```nginx
location / {
proxy_pass http://localhost:3001;
# Important: Pass the Authorization header
proxy_pass_header Authorization;
proxy_set_header Authorization $http_authorization;
# Other standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Alternative Fix Using Traefik
If using Traefik, ensure headers are passed:
```yaml
services:
picpeak:
labels:
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
```
## Testing
1. Check if Authorization header is reaching the backend:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
```
2. Check nginx logs to see if the header is present:
```bash
tail -f /var/log/nginx/access.log
```
## Docker Compose Fix
If using docker-compose with nginx proxy, add:
```yaml
environment:
- NGINX_PROXY_PASS_HEADER=Authorization
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.28",
"version": "1.0.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.28",
"version": "1.0.41",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.28",
"version": "1.0.41",
"type": "module",
"scripts": {
"dev": "vite",
+85 -26
View File
@@ -18,6 +18,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -32,6 +34,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) {
const allowedNewFiles = 500 - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
return;
}
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
@@ -41,38 +57,64 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > 500) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
return;
}
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach((file, index) => {
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
formData.append('photos', file);
});
// For large uploads, chunk the files to prevent memory issues
const CHUNK_SIZE = 50; // Upload 50 files at a time
const chunks = [];
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
// Debug: Log FormData contents
console.log('FormData entries:');
for (let pair of formData.entries()) {
console.log(pair[0], pair[1]);
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
}
setTotalChunks(chunks.length);
let totalUploaded = 0;
let failedFiles = [];
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
// Don't set Content-Type header - axios will set it with the boundary
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(progress);
}
},
});
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
console.log('Upload result:', response.data);
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
}
},
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files
setSelectedFiles([]);
@@ -80,8 +122,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
fileInputRef.current.value = '';
}
// Show success message
toast.success(t('toast.uploadSuccess'));
// Show appropriate message
if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
);
}
// Call callback
if (onUploadComplete) {
@@ -93,6 +142,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} finally {
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
}
};
@@ -203,7 +254,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 mb-1">
<span>{t('upload.uploading')}</span>
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
@@ -212,6 +266,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 mt-1">
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
</p>
)}
</div>
)}
</div>
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
let imageUrl = src;
// Build full URL for the image
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
// For API paths that start with /admin, we need to prepend /api
const fullImageUrl = imageUrl.startsWith('/admin')
? buildResourceUrl(`/api${imageUrl}`)
: imageUrl.startsWith('/')
? buildResourceUrl(imageUrl)
: imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
// console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
@@ -52,7 +52,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { watermarkEnabled } = useWatermarkSettings();
// Fetch photos
const { data, isLoading, error } = useGalleryPhotos(slug);
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Debug logging
useEffect(() => {
@@ -294,11 +294,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
if (error || !data) {
// Check if it's an authentication error (401)
const is401Error = (error as any)?.response?.status === 401;
if (is401Error) {
// Authentication failed - logout and let the parent component handle re-authentication
logout();
return null;
}
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
<Button onClick={() => refetch()} className="mt-4">
{t('gallery.tryAgain')}
</Button>
</div>
@@ -162,7 +162,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Close"
>
<X className="w-6 h-6 text-white" />
@@ -171,7 +171,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Navigation buttons */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6 text-white" />
@@ -179,20 +179,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
{/* Bottom toolbar */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-white">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
<p className="font-medium">{currentPhoto.filename}</p>
</div>
<div className="flex items-center gap-2">
@@ -231,7 +230,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Image container */}
<div
className="absolute inset-0 flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center z-0"
onClick={handleImageClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -257,7 +256,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
{/* Touch/swipe indicators for mobile */}
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden">
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
Swipe to navigate
</div>
</div>
+37 -14
View File
@@ -32,14 +32,24 @@ api.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// For gallery routes, get the slug from the URL path
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
}
@@ -73,21 +83,34 @@ api.interceptors.response.use(
}
if (error.response?.status === 401) {
// Redirect to appropriate login
// Check if it's an admin route
const isAdminRoute = error.config?.url?.includes('/admin');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
window.location.href = '/admin/login';
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login';
}
} else {
// For gallery routes, clear gallery-specific token and redirect
const currentPath = window.location.pathname;
const pathParts = currentPath.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
window.location.href = `/gallery/${gallerySlug}`;
// For gallery routes, check if the error is from a gallery API call
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
if (currentPath.startsWith('/gallery/')) {
// If we have a gallery match from the API URL, clear that specific gallery's token
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else {
// We're not on a gallery page but got a 401 from a gallery API
// This shouldn't happen in normal flow, but if it does, redirect to homepage
window.location.href = '/';
}
}
}
+2
View File
@@ -18,6 +18,8 @@ export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
// Add a small delay to ensure auth token is properly set
retryDelay: 100,
});
};
+6 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { settingsService } from '../services/settings.service';
import { api } from '../config/api';
export function useWatermarkSettings() {
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
@@ -8,11 +8,13 @@ export function useWatermarkSettings() {
useEffect(() => {
const fetchSettings = async () => {
try {
const settings = await settingsService.getSettingsByType('branding');
const brandingSettings = settingsService.formatBrandingSettings(settings);
setWatermarkEnabled(brandingSettings.watermark_enabled);
// Use public settings endpoint that doesn't require authentication
const response = await api.get('/public/settings');
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
} catch (error) {
console.error('Failed to fetch watermark settings:', error);
// Default to false if we can't fetch settings
setWatermarkEnabled(false);
} finally {
setLoading(false);
}
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen"
"uploadPhotos": "Fotos hochladen",
"maxFilesReached": "Maximal 500 Dateien erlaubt",
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden"
},
"navigation": {
"dashboard": "Dashboard",
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos"
"uploadPhotos": "Upload Photos",
"maxFilesReached": "Maximum 500 files allowed",
"someFilesSkipped": "Some files were skipped (500 file limit)",
"tooManyFiles": "Maximum 500 files can be uploaded at once"
},
"navigation": {
"dashboard": "Dashboard",
+66 -8
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -33,12 +33,47 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
@@ -272,8 +307,8 @@ export const EventsListPage: React.FC = () => {
</Card>
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
@@ -347,21 +382,38 @@ export const EventsListPage: React.FC = () => {
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && (
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -374,7 +426,10 @@ export const EventsListPage: React.FC = () => {
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
@@ -385,6 +440,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -397,6 +453,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -409,6 +466,7 @@ export const EventsListPage: React.FC = () => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
+3 -3
View File
@@ -100,7 +100,7 @@ export const settingsService = {
formData.append('logo', file);
const response = await api.post<{ logoUrl: string }>(
'/api/admin/settings/logo',
'/admin/settings/logo',
formData,
{
headers: {
@@ -118,7 +118,7 @@ export const settingsService = {
formData.append('favicon', file);
const response = await api.post<{ faviconUrl: string }>(
'/api/admin/settings/favicon',
'/admin/settings/favicon',
formData,
{
headers: {
@@ -136,7 +136,7 @@ export const settingsService = {
formData.append('watermarkLogo', file);
const response = await api.post<{ watermarkLogoUrl: string }>(
'/api/admin/settings/branding/watermark-logo',
'/admin/settings/branding/watermark-logo',
formData,
{
headers: {