Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e | |||
| c844f634c8 | |||
| 1d94398e2d | |||
| a2551dc0ad | |||
| 32821934e6 | |||
| b9c28e52cd | |||
| c94b6268cf | |||
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d | |||
| 0a2b010332 | |||
| 6906c8bcf7 | |||
| ac48bfdd0d | |||
| ec99243b6f | |||
| 9932621e14 | |||
| 0fb17c78fa |
@@ -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."
|
||||
@@ -1,13 +1,17 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](https://reactjs.org/)
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://nodejs.org/)
|
||||
[](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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 🌟 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
|
||||

|
||||
#### What makes PicPeak's interface special:
|
||||
|
||||
### Client Gallery View
|
||||

|
||||
|
||||
### Mobile Experience
|
||||

|
||||
- **🎨 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.
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
Executable
+132
@@ -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);
|
||||
});
|
||||
Executable
+107
@@ -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);
|
||||
});
|
||||
Executable
+132
@@ -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);
|
||||
});
|
||||
Executable
+141
@@ -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);
|
||||
});
|
||||
Executable
+86
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 |
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.28",
|
||||
"version": "1.0.41",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
@@ -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 = '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user