Compare commits

..

17 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:49:45 +02:00
23 changed files with 858 additions and 40 deletions
+43 -3
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
@@ -19,11 +20,22 @@ jobs:
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Debug - Show current branch and status
run: |
echo "Current branch:"
git branch -a
echo "Git status:"
git status
echo "Remote info:"
git remote -v
- name: Create filtered branch
run: |
# Clean up any existing github-mirror branch
git branch -D github-mirror || true
# Create a new branch for GitHub
git checkout --orphan -b github-mirror
git checkout --orphan github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
@@ -43,17 +55,45 @@ jobs:
git rm -r --cached CLAUDE.md || true
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
git rm -r --cached logs/ || true
git rm -r --cached frontend/.claudedocs/ || true
git rm -r --cached test-maintenance.sh || true
git rm -r --cached storage/ || true
# Commit the changes
git commit -m "Remove sensitive files for GitHub mirror" || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
exit 1
else
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote added:"
git remote -v
# Force push the filtered branch to GitHub main
git push github github-mirror:main --force
echo "Pushing to GitHub..."
git push github github-mirror:main --force
echo "Push completed successfully!"
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
+1 -2
View File
@@ -11,8 +11,7 @@
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
> 📸 *Gallery preview will be updated soon with latest interface*
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🌟 Why Choose PicPeak?
Binary file not shown.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.33",
"version": "1.0.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.33",
"version": "1.0.41",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.33",
"version": "1.0.41",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to check storage directory structure and verify files
* Usage: node scripts/check-storage.js [eventSlug]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
async function checkDirectory(dirPath, description) {
try {
await fs.access(dirPath);
const stats = await fs.stat(dirPath);
const files = await fs.readdir(dirPath);
console.log(`${description}: ${dirPath}`);
console.log(` - Files/Folders: ${files.length}`);
console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`);
return true;
} catch (error) {
console.log(`${description}: ${dirPath} - ${error.message}`);
return false;
}
}
async function checkStorageStructure(eventSlug = null) {
console.log('Checking storage structure...');
console.log(`Storage base path: ${STORAGE_PATH}\n`);
// Check main directories
await checkDirectory(STORAGE_PATH, 'Storage root');
await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory');
await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events');
await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events');
await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails');
await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads');
console.log('\n---\n');
// If event slug provided, check specific event
if (eventSlug) {
console.log(`Checking specific event: ${eventSlug}`);
const event = await db('events').where('slug', eventSlug).first();
if (!event) {
console.log(`✗ Event not found in database: ${eventSlug}`);
return;
}
console.log(`✓ Event found in database:`);
console.log(` - ID: ${event.id}`);
console.log(` - Name: ${event.event_name}`);
console.log(` - Active: ${event.is_active}`);
console.log(` - Archived: ${event.is_archived}`);
// Check event directory
const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug);
const eventExists = await checkDirectory(eventDir, 'Event directory');
if (eventExists) {
const files = await fs.readdir(eventDir);
console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`);
}
// Check photos in database
const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path');
console.log(`\nDatabase photos: ${photos.length}`);
// Check if photo files exist
let existingPhotos = 0;
let missingPhotos = 0;
let existingThumbnails = 0;
let missingThumbnails = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
try {
await fs.access(photoPath);
existingPhotos++;
} catch {
missingPhotos++;
console.log(` ✗ Missing photo: ${photo.path}`);
}
if (photo.thumbnail_path) {
const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, ''));
try {
await fs.access(thumbPath);
existingThumbnails++;
} catch {
missingThumbnails++;
console.log(` ✗ Missing thumbnail: ${photo.thumbnail_path}`);
}
}
}
console.log(`\nFile check summary:`);
console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`);
console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`);
} else {
// List all event directories
try {
const activeDir = path.join(STORAGE_PATH, 'events/active');
const eventDirs = await fs.readdir(activeDir);
console.log(`Active event directories: ${eventDirs.length}`);
for (const dir of eventDirs.slice(0, 10)) {
console.log(` - ${dir}`);
}
if (eventDirs.length > 10) {
console.log(` ... and ${eventDirs.length - 10} more`);
}
} catch (error) {
console.log('Could not list event directories:', error.message);
}
}
}
// Parse command line arguments
const eventSlug = process.argv[2] || null;
// Run the script
checkStorageStructure(eventSlug).then(async () => {
await db.destroy();
console.log('\nStorage check complete');
}).catch(async error => {
console.error('Error:', error);
await db.destroy();
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Script to clean up orphaned and temporary thumbnails
* Usage: node scripts/cleanup-thumbnails.js [--dry-run]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function cleanupThumbnails(dryRun = false) {
console.log('Starting thumbnail cleanup...');
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);
try {
// Get all thumbnail files
const files = await fs.readdir(THUMBNAILS_DIR);
console.log(`Found ${files.length} files in thumbnails directory`);
// Get all valid thumbnail paths from database
const validThumbnails = await db('photos')
.whereNotNull('thumbnail_path')
.select('thumbnail_path');
const validPaths = new Set(
validThumbnails.map(t => path.basename(t.thumbnail_path))
);
console.log(`Found ${validPaths.size} valid thumbnails in database\n`);
let tempCount = 0;
let orphanedCount = 0;
let validCount = 0;
let deletedCount = 0;
for (const file of files) {
// Skip directories
const filePath = path.join(THUMBNAILS_DIR, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) continue;
// Check if it's a temporary file
if (file.startsWith('thumb_temp_')) {
tempCount++;
console.log(`Temporary file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
}
// Check if it's an orphaned thumbnail
else if (!validPaths.has(file)) {
orphanedCount++;
console.log(`Orphaned file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
} else {
validCount++;
}
}
console.log('\n--- Summary ---');
console.log(`Total files: ${files.length}`);
console.log(`Valid thumbnails: ${validCount}`);
console.log(`Temporary files: ${tempCount}`);
console.log(`Orphaned files: ${orphanedCount}`);
if (!dryRun) {
console.log(`Deleted files: ${deletedCount}`);
} else {
console.log(`Files to be deleted: ${tempCount + orphanedCount}`);
}
} catch (error) {
console.error('Error during cleanup:', error);
process.exit(1);
}
}
// Parse command line arguments
const dryRun = process.argv.includes('--dry-run');
// Run the cleanup
cleanupThumbnails(dryRun).then(async () => {
await db.destroy();
console.log('\nCleanup complete');
}).catch(async error => {
console.error('Cleanup failed:', error);
await db.destroy();
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to diagnose thumbnail serving issues
* Usage: node scripts/diagnose-thumbnails.js <eventId>
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function diagnoseThumbnails(eventId) {
if (!eventId) {
console.error('Usage: node scripts/diagnose-thumbnails.js <eventId>');
process.exit(1);
}
console.log(`Diagnosing thumbnails for event ID: ${eventId}`);
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`);
try {
// Get event info
const event = await db('events').where('id', eventId).first();
if (!event) {
console.error(`Event not found with ID: ${eventId}`);
return;
}
console.log(`Event: ${event.event_name} (${event.slug})`);
console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`);
// Get photos for this event
const photos = await db('photos')
.where('event_id', eventId)
.select('id', 'filename', 'path', 'thumbnail_path');
console.log(`Found ${photos.length} photos in database\n`);
let missingThumbnails = 0;
let existingThumbnails = 0;
let pathIssues = [];
for (const photo of photos.slice(0, 10)) { // Check first 10 photos
console.log(`Photo ID ${photo.id}: ${photo.filename}`);
console.log(` Photo path: ${photo.path}`);
console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`);
if (photo.thumbnail_path) {
// Expected thumbnail filename
const expectedThumbName = `thumb_${photo.filename}`;
const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName);
// Check if thumbnail exists
try {
await fs.access(expectedThumbPath);
console.log(` ✓ Thumbnail exists at: ${expectedThumbName}`);
existingThumbnails++;
// Check if DB path matches expected path
const dbThumbName = path.basename(photo.thumbnail_path);
if (dbThumbName !== expectedThumbName) {
console.log(` ⚠ Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`);
pathIssues.push({
photoId: photo.id,
dbPath: photo.thumbnail_path,
expectedPath: `thumbnails/${expectedThumbName}`
});
}
} catch {
console.log(` ✗ Thumbnail missing: ${expectedThumbName}`);
missingThumbnails++;
}
} else {
console.log(` ✗ No thumbnail path in database`);
missingThumbnails++;
}
console.log('');
}
console.log('--- Summary ---');
console.log(`Existing thumbnails: ${existingThumbnails}`);
console.log(`Missing thumbnails: ${missingThumbnails}`);
console.log(`Path issues: ${pathIssues.length}`);
if (pathIssues.length > 0) {
console.log('\n--- Path Issues ---');
console.log('The following photos have incorrect thumbnail paths in the database:');
for (const issue of pathIssues) {
console.log(`Photo ID ${issue.photoId}:`);
console.log(` Current: ${issue.dbPath}`);
console.log(` Should be: ${issue.expectedPath}`);
}
console.log('\nTo fix path issues, run:');
console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`);
}
// Check for any thumbnails in the directory that match this event
const files = await fs.readdir(THUMBNAILS_DIR);
const eventThumbnails = files.filter(f => {
// Try to match thumbnails for this event
for (const photo of photos) {
if (f === `thumb_${photo.filename}`) return true;
}
return false;
});
console.log(`\n--- Filesystem Check ---`);
console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`);
} catch (error) {
console.error('Error during diagnosis:', error);
process.exit(1);
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the diagnosis
diagnoseThumbnails(eventId).then(async () => {
await db.destroy();
console.log('\nDiagnosis complete');
}).catch(async error => {
console.error('Diagnosis failed:', error);
await db.destroy();
process.exit(1);
});
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Script to regenerate missing thumbnails for photos in the database
* Usage: node scripts/regenerate-thumbnails.js [eventId]
*/
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../src/database/db');
// Configuration
const THUMBNAIL_SIZE = 300;
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function ensureDirectoryExists(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
console.log(`Created directory: ${dirPath}`);
}
}
async function generateThumbnail(photoPath, thumbnailPath) {
try {
await sharp(photoPath)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return true;
} catch (error) {
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
return false;
}
}
async function regenerateThumbnails(eventId = null) {
try {
console.log('Starting thumbnail regeneration...');
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
// Ensure thumbnails directory exists
await ensureDirectoryExists(THUMBNAILS_DIR);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.select(
'photos.id',
'photos.filename',
'photos.path',
'photos.thumbnail_path',
'events.slug as event_slug'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
const thumbnailFilename = `thumb_${photo.filename}`;
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
try {
// Check if photo file exists
await fs.access(photoPath);
// Check if thumbnail already exists
try {
await fs.access(thumbnailPath);
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
skipCount++;
continue;
} catch {
// Thumbnail doesn't exist, generate it
}
console.log(`Generating thumbnail for ${photo.filename}...`);
const success = await generateThumbnail(photoPath, thumbnailPath);
if (success) {
// Update database with thumbnail path
await db('photos')
.where('id', photo.id)
.update({
thumbnail_path: `thumbnails/${thumbnailFilename}`
});
successCount++;
console.log(`✓ Generated thumbnail for ${photo.filename}`);
} else {
errorCount++;
}
} catch (error) {
console.error(`✗ Photo file not found: ${photoPath}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Successfully generated: ${successCount}`);
console.log(`- Skipped (already exist): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
console.log(`- Total processed: ${photos.length}`);
} catch (error) {
console.error('Error during thumbnail regeneration:', error);
process.exit(1);
} finally {
await db.destroy();
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the script
regenerateThumbnails(eventId).then(() => {
console.log('Script completed successfully');
process.exit(0);
}).catch(error => {
console.error('Script failed:', error);
process.exit(1);
});
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Script to test photo authentication
* Usage: node scripts/test-photo-auth.js <jwt-token>
*/
const axios = require('axios');
async function testPhotoAuth(token) {
if (!token) {
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
process.exit(1);
}
const baseUrl = process.env.API_URL || 'http://localhost:3001';
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
console.log(`Base URL: ${baseUrl}\n`);
// Test URLs
const tests = [
{
name: 'Thumbnail via static route',
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Photo via static route',
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Gallery photos API',
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
headers: { 'Authorization': `Bearer ${token}` }
}
];
for (const test of tests) {
console.log(`Testing: ${test.name}`);
console.log(`URL: ${test.url}`);
try {
const response = await axios.get(test.url, {
headers: test.headers,
validateStatus: () => true // Don't throw on any status
});
console.log(`Status: ${response.status}`);
console.log(`Headers:`, response.headers['content-type']);
if (response.status === 200) {
if (test.name.includes('API')) {
console.log(`Photos count: ${response.data.photos?.length || 0}`);
} else {
console.log(`Content length: ${response.headers['content-length']} bytes`);
}
} else {
console.log(`Error:`, response.data);
}
} catch (error) {
console.log(`Network error:`, error.message);
}
console.log('---\n');
}
// Decode token to show info
try {
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
console.log('Token payload:', payload);
} catch (error) {
console.log('Failed to decode token');
}
}
// Get token from command line
const token = process.argv[2];
testPhotoAuth(token).catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+18 -1
View File
@@ -363,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();
+2 -1
View File
@@ -119,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) {
+43 -3
View File
@@ -60,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' });
}
@@ -132,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 ? `/${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,
@@ -316,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 {
View File
+59
View File
@@ -0,0 +1,59 @@
# Nginx Configuration Fix for Photo Authentication
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
## Common Issue
The `Authorization` header is often not passed through by default in nginx proxy configurations.
## Fix
Add these lines to your nginx configuration for the PicPeak location block:
```nginx
location / {
proxy_pass http://localhost:3001;
# Important: Pass the Authorization header
proxy_pass_header Authorization;
proxy_set_header Authorization $http_authorization;
# Other standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Alternative Fix Using Traefik
If using Traefik, ensure headers are passed:
```yaml
services:
picpeak:
labels:
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
```
## Testing
1. Check if Authorization header is reaching the backend:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
```
2. Check nginx logs to see if the header is present:
```bash
tail -f /var/log/nginx/access.log
```
## Docker Compose Fix
If using docker-compose with nginx proxy, add:
```yaml
environment:
- NGINX_PROXY_PASS_HEADER=Authorization
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.33",
"version": "1.0.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.33",
"version": "1.0.41",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.33",
"version": "1.0.41",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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}`
@@ -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>
+6 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { settingsService } from '../services/settings.service';
import { api } from '../config/api';
export function useWatermarkSettings() {
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
@@ -8,11 +8,13 @@ export function useWatermarkSettings() {
useEffect(() => {
const fetchSettings = async () => {
try {
const settings = await settingsService.getSettingsByType('branding');
const brandingSettings = settingsService.formatBrandingSettings(settings);
setWatermarkEnabled(brandingSettings.watermark_enabled);
// Use public settings endpoint that doesn't require authentication
const response = await api.get('/public/settings');
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
} catch (error) {
console.error('Failed to fetch watermark settings:', error);
// Default to false if we can't fetch settings
setWatermarkEnabled(false);
} finally {
setLoading(false);
}
+66 -8
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -33,12 +33,47 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
@@ -272,8 +307,8 @@ export const EventsListPage: React.FC = () => {
</Card>
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
@@ -347,21 +382,38 @@ export const EventsListPage: React.FC = () => {
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && (
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -374,7 +426,10 @@ export const EventsListPage: React.FC = () => {
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
@@ -385,6 +440,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -397,6 +453,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -409,6 +466,7 @@ export const EventsListPage: React.FC = () => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
+3 -3
View File
@@ -100,7 +100,7 @@ export const settingsService = {
formData.append('logo', file);
const response = await api.post<{ logoUrl: string }>(
'/api/admin/settings/logo',
'/admin/settings/logo',
formData,
{
headers: {
@@ -118,7 +118,7 @@ export const settingsService = {
formData.append('favicon', file);
const response = await api.post<{ faviconUrl: string }>(
'/api/admin/settings/favicon',
'/admin/settings/favicon',
formData,
{
headers: {
@@ -136,7 +136,7 @@ export const settingsService = {
formData.append('watermarkLogo', file);
const response = await api.post<{ watermarkLogoUrl: string }>(
'/api/admin/settings/branding/watermark-logo',
'/admin/settings/branding/watermark-logo',
formData,
{
headers: {