Compare commits

...

17 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
Gitea Actions Bot 4bcca58a11 chore: bump version to 1.0.28
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 18:54:11 +00:00
paul 9fa5ba1cf7 fix: update GitHub mirror workflow to use orphan branch
Test and Lint / backend-test (push) Successful in 1m5s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
2025-07-14 20:49:53 +02:00
paul 88919fa0d3 fix: critical boolean compatibility for PostgreSQL/SQLite
Fixed boolean value handling differences between databases:
- SQLite stores booleans as 0/1, PostgreSQL as true/false
- Add formatBoolean() calls to critical queries that were failing

Files fixed:
- adminEvents.js: Fixed status filters and archive queries
- adminDashboard.js: Fixed active/archived event counts
- expirationChecker.js: Fixed expiration checking queries
- dbCompat.js: Updated to avoid circular dependency

Added migration 024 to:
- Enable foreign keys for SQLite (PRAGMA foreign_keys = ON)
- Document boolean compatibility requirements

This fixes queries returning 0 results in SQLite when checking
boolean columns like is_active, is_archived. Critical for
proper event management and expiration handling.

Note: 23 more boolean comparisons remain to be fixed in other files.
2025-07-14 20:49:53 +02:00
Gitea Actions Bot 5e43fc9cd9 chore: bump version to 1.0.27
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 18:43:02 +00:00
paul f053f42b6d fix: comprehensive PostgreSQL/SQLite compatibility fixes
Mirror to GitHub / mirror (push) Successful in 19s
Test and Lint / backend-test (push) Successful in 1m8s
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
continuous-integration/drone/push Build is passing
Critical fixes for database compatibility issues:

INSERT operations:
- Fix all INSERT queries to use .returning('id')
- Handle both PostgreSQL (returns objects) and SQLite (returns IDs)
- Fixed in: events.js, adminArchives.js, adminEvents-enhanced.js, create-test-event.js

Date operations:
- Replace SQLite-specific db.raw("datetime('now', '+30 days')")
- Use JavaScript Date objects for cross-database compatibility
- Fixed in: adminArchives.js

Database utilities:
- Add dbCompat.js utility for handling database differences
- Provides consistent API for inserts, dates, booleans, and DB-specific operations
- Centralized database compatibility logic

Migration:
- Add migration 023 documenting PostgreSQL compatibility requirements
- Ensures future developers are aware of compatibility needs

This resolves all 'not iterable' errors and ensures the application
works correctly with both PostgreSQL (production) and SQLite (development).
2025-07-14 20:38:37 +02:00
47 changed files with 763 additions and 326 deletions
@@ -1,39 +0,0 @@
name: Mirror to GitHub (Archive Method)
on:
push:
branches:
- main
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Mirror using git archive
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure git
git config --global user.name "Gitea Mirror Bot"
git config --global user.email "bot@noreply.gitea.local"
# Copy gitattributes
cp .gitattributes-github .gitattributes
# Create archive excluding files
git archive --format=tar HEAD | tar -x -C /tmp/export
# Initialize new repo in export directory
cd /tmp/export
git init
git add .
git commit -m "Mirror from Gitea: $(date '+%Y-%m-%d %H:%M:%S')"
# Push to GitHub
git remote add origin https://x-access-token:${GITHUB_TOKEN}@github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git
git push -f origin main
-42
View File
@@ -1,42 +0,0 @@
name: Mirror to GitHub (Rsync Method)
on:
push:
branches:
- main
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Prepare mirror directory
run: |
# Create mirror directory
mkdir -p /tmp/github-mirror
# Use rsync to copy files, excluding sensitive ones
rsync -av --exclude-from='.github-mirror-exclude' ./ /tmp/github-mirror/
- name: Push to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd /tmp/github-mirror
# Initialize git repo
git init
git config user.name "Gitea Mirror Bot"
git config user.email "bot@noreply.gitea.local"
# Add all files and commit
git add .
git commit -m "Mirror from Gitea: $(git --git-dir=$GITHUB_WORKSPACE/.git log -1 --format='%h %s')"
# Push to GitHub
git remote add origin https://x-access-token:${GITHUB_TOKEN}@github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git
git push -f origin main
+3 -2
View File
@@ -21,8 +21,9 @@ jobs:
- name: Create filtered branch
run: |
# Create a new branch for GitHub
git checkout -b github-mirror
git checkout --orphan -b github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
@@ -49,7 +50,7 @@ jobs:
- name: Push to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
+34 -12
View File
@@ -1,13 +1,18 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
> 📸 *Gallery preview will be updated soon with latest interface*
## 🌟 Why Choose PicPeak?
@@ -124,17 +129,34 @@ Found a security issue? Please email security@example.com
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>View Gallery Examples</summary>
<summary>👆 Click to see more interface details</summary>
### Admin Dashboard
![Admin Dashboard](https://github.com/the-luap/picpeak/assets/placeholder-admin.png)
#### What makes PicPeak's interface special:
### Client Gallery View
![Gallery View](https://github.com/the-luap/picpeak/assets/placeholder-gallery.png)
### Mobile Experience
![Mobile View](https://github.com/the-luap/picpeak/assets/placeholder-mobile.png)
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
@@ -0,0 +1,22 @@
/**
* Ensure PostgreSQL compatibility for all insert operations
* This migration doesn't change the schema but ensures all tables
* are compatible with .returning() syntax
*/
exports.up = async function(knex) {
// This migration is informational only
// All insert operations should use .returning('id') going forward
console.log('PostgreSQL compatibility check:');
console.log('- All INSERT operations should use .returning("id")');
console.log('- All date operations should use ISO strings');
console.log('- Boolean values are handled automatically by Knex');
return Promise.resolve();
};
exports.down = async function(knex) {
// No rollback needed
return Promise.resolve();
};
@@ -0,0 +1,29 @@
/**
* Fix boolean compatibility issues between PostgreSQL and SQLite
* This migration updates the database configuration and existing data
*/
exports.up = async function(knex) {
const isPostgres = knex.client.config.client === 'pg';
if (!isPostgres) {
// Enable foreign keys for SQLite
await knex.raw('PRAGMA foreign_keys = ON');
// Note: SQLite stores booleans as 0/1
// No data migration needed as Knex handles this automatically
// But queries must use formatBoolean() helper
console.log('SQLite boolean compatibility check:');
console.log('- SQLite stores booleans as 0/1');
console.log('- All boolean comparisons should use formatBoolean() helper');
console.log('- Foreign keys enabled');
}
return Promise.resolve();
};
exports.down = async function(knex) {
// No rollback needed
return Promise.resolve();
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.26",
"version": "1.0.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.26",
"version": "1.0.33",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.26",
"version": "1.0.33",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+2 -1
View File
@@ -36,7 +36,8 @@ async function createTestEvent() {
await db('events').where('slug', eventData.slug).delete();
// Insert new event
const [eventId] = await db('events').insert(eventData);
const insertResult = await db('events').insert(eventData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
console.log('Event created with ID:', eventId);
console.log('\nTest event created successfully!');
+9 -6
View File
@@ -125,9 +125,9 @@ const authLimiter = rateLimit({
app.use('/api/', limiter);
app.use('/api/auth', authLimiter);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -146,14 +146,17 @@ const setCorsHeaders = (req, res, next) => {
// Import secure static middleware
const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/events/active')));
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/thumbnails')));
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Health check endpoint
app.get('/health', async (req, res) => {
+83
View File
@@ -0,0 +1,83 @@
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
describe('Database Compatibility', () => {
// Save original env
const originalEnv = process.env.DATABASE_CLIENT;
afterEach(() => {
// Restore original env after each test
if (originalEnv) {
process.env.DATABASE_CLIENT = originalEnv;
} else {
delete process.env.DATABASE_CLIENT;
}
});
describe('formatBoolean', () => {
test('should format boolean values correctly', () => {
// Mock for SQLite
process.env.DATABASE_CLIENT = 'sqlite3';
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
// Mock for PostgreSQL
process.env.DATABASE_CLIENT = 'pg';
expect(formatBoolean(true)).toBe(true);
expect(formatBoolean(false)).toBe(false);
// Default (no env var) should be SQLite
delete process.env.DATABASE_CLIENT;
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
});
});
describe('isPostgreSQL', () => {
test('should detect PostgreSQL correctly', () => {
process.env.DATABASE_CLIENT = 'pg';
expect(isPostgreSQL()).toBe(true);
process.env.DATABASE_CLIENT = 'sqlite3';
expect(isPostgreSQL()).toBe(false);
delete process.env.DATABASE_CLIENT;
expect(isPostgreSQL()).toBe(false); // Default to SQLite
});
});
describe('formatDateForDB', () => {
test('should format dates as ISO strings', () => {
const date = new Date('2024-01-15T10:30:00Z');
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('addDays', () => {
test('should add days correctly', () => {
const date = new Date('2024-01-15');
const result = addDays(date, 30);
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
const negativeResult = addDays(date, -7);
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
});
});
describe('insertAndGetId', () => {
test('should handle PostgreSQL result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([{ id: 123 }])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(123);
});
test('should handle SQLite result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([456])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(456);
});
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+3 -2
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
/**
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function adminAuth(req, res, next) {
try {
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first();
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
+8 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
@@ -10,7 +11,13 @@ async function verifyGalleryAccess(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
+27 -15
View File
@@ -1,12 +1,15 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
@@ -25,9 +28,22 @@ async function photoAuth(req, res, next) {
// Check if it's a gallery token
if (decoded.type === 'gallery') {
// For thumbnails, we accept any valid gallery token
// For thumbnails, we need to verify the token is for a valid event
if (!eventSlug) {
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
// Extract event ID from the decoded token
if (decoded.eventId) {
const event = await db('events')
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
}
}
// Fallback to slug
const event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -35,7 +51,9 @@ async function photoAuth(req, res, next) {
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events')
.where({ slug: eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -45,18 +63,12 @@ async function photoAuth(req, res, next) {
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
if (!eventSlug) {
// For thumbnails with admin token, allow access
return next();
}
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
return next();
}
// For both thumbnails and photos with admin token, allow access
return next();
}
} catch (err) {
// Token invalid, fall through to password check
console.error('JWT verification failed:', err.message);
}
}
@@ -67,12 +79,12 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), we require JWT token
if (!eventSlug) {
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
+15 -9
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
// Get total count
const totalCount = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
db.raw('SUM(photos.size_bytes) as total_size')
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', true)
.where('events.is_archived', formatBoolean(true))
.groupBy('events.id')
.orderBy('events.archived_at', 'desc')
.limit(limit)
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -211,12 +212,14 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
categoriesMap.set(categoryName, existingCategory.id);
} else {
// Create the category if it doesn't exist
const [newCategoryId] = await db('photo_categories').insert({
const insertResult = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
created_at: new Date()
});
}).returning('id');
const newCategoryId = insertResult[0]?.id || insertResult[0];
categoriesMap.set(categoryName, newCategoryId);
}
}
@@ -266,6 +269,9 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
}
// Update event status
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
await db('events')
.where('id', req.params.id)
.update({
@@ -273,7 +279,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
is_active: true,
archive_path: null,
archived_at: null,
expires_at: db.raw("datetime('now', '+30 days')") // Reset expiration
expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs
});
// Log activity
@@ -298,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) {
@@ -347,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) {
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const router = express.Router();
@@ -8,7 +9,7 @@ const router = express.Router();
router.get('/global', adminAuth, async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', true)
.where('is_global', formatBoolean(true))
.orderBy('name', 'asc');
res.json(categories);
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', true);
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
+6 -5
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
// Get dashboard statistics
@@ -9,8 +10,8 @@ router.get('/stats', adminAuth, async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.count('id as count')
.first();
@@ -20,8 +21,8 @@ router.get('/stats', adminAuth, async (req, res) => {
const now = new Date();
const expiringEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', now.toISOString())
.count('id as count')
@@ -56,7 +57,7 @@ router.get('/stats', adminAuth, async (req, res) => {
// Get archived events count
const archivedEvents = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
+5 -2
View File
@@ -83,7 +83,7 @@ router.post('/', adminAuth, [
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
@@ -99,7 +99,10 @@ router.post('/', adminAuth, [
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
});
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Log activity
await logActivity('event_created',
+7 -6
View File
@@ -11,6 +11,7 @@ const { archiveEvent } = require('../services/archiveService');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { formatDate } = require('../utils/dateFormatter');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { formatBoolean } = require('../utils/dbCompat');
// Create new event
router.post('/', adminAuth, [
@@ -183,17 +184,17 @@ router.get('/', adminAuth, async (req, res) => {
// Apply status filter
if (status === 'active') {
query = query.where('is_active', true).where('is_archived', false);
query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false));
} else if (status === 'archived') {
query = query.where('is_archived', true);
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', false).where('is_archived', false);
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
query = query
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', new Date().toISOString());
}
@@ -554,7 +555,7 @@ router.post('/bulk-archive', adminAuth, [
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', false);
.where('is_archived', formatBoolean(false));
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
+143 -72
View File
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
});
// Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => {
upload.array('photos', 500)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
@@ -136,90 +143,122 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
}
const uploadedPhotos = [];
const errors = [];
// Process each uploaded file
for (const file of req.files) {
let trx;
// Process files in batches to optimize database operations
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
const batch = req.files.slice(i, i + BATCH_SIZE);
// Start a single transaction for the batch
const trx = await db.transaction();
try {
// Start transaction for atomic counter update
trx = await db.transaction();
// Get and increment the counter for this category
let counter = 1;
// Get initial counter for this batch
let batchCounter = 1;
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: parsedCategoryId })
.forUpdate()
.first();
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
batchCounter = (categoryData.photo_counter || 0) + 1;
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
batchCounter = (uncategorizedCount.count || 0) + 1;
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
const batchPhotos = [];
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
try {
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Update file object
file.filename = newFilename;
file.path = newPath;
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath;
// Prepare photo data for batch insert
batchPhotos.push({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual',
size_bytes: file.size
});
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
// Delete the file if it was partially processed
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
// Update file object
file.filename = newFilename;
file.path = newPath;
// Batch insert all photos from this batch
if (batchPhotos.length > 0) {
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
// Update category counter if needed
if (category) {
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
}
// Add to uploaded photos array
batchPhotos.forEach((photo, index) => {
uploadedPhotos.push({
id: insertedIds[index]?.id || insertedIds[index],
filename: photo.filename,
size: photo.size_bytes,
category_id: photo.category_id
});
});
}
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction
// Commit the batch transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
category_id: parsedCategoryId || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
if (trx) await trx.rollback();
// Continue with other files
console.error(`Error processing batch starting at index ${i}:`, error);
await trx.rollback();
// Try to clean up files from failed batch
for (const file of batch) {
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
}
@@ -230,10 +269,22 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
// Prepare response
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos
});
photos: uploadedPhotos,
totalFiles: req.files.length,
successCount: uploadedPhotos.length,
failureCount: errors.length
};
// Include error details if any files failed
if (errors.length > 0) {
response.errors = errors;
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
}
res.json(response);
} catch (error) {
console.error('Error uploading photos:', error);
res.status(500).json({ error: 'Failed to upload photos' });
@@ -501,8 +552,8 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
url: `/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -595,4 +646,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
}
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
const photos = await db('photos').where({ event_id: eventId }).limit(5);
res.json({
event: event || 'Not found',
photoCount: photoCount.count,
samplePhotos: photos,
storagePath: getStoragePath()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+2 -1
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const router = express.Router();
@@ -513,7 +514,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
// Get archive storage
const archives = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.whereNotNull('archive_path')
.select('archive_path');
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const 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' });
}
+10 -6
View File
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
@@ -64,7 +65,7 @@ router.post('/', adminAuth, [
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
@@ -76,7 +77,10 @@ router.post('/', adminAuth, [
color_theme,
share_link: shareLink,
expires_at
});
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
@@ -110,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');
@@ -159,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) {
@@ -185,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 });
+5 -28
View File
@@ -1,46 +1,23 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events')
.where({ id: decoded.eventId, is_active: true, is_archived: false })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = event;
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: true, is_archived: false })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.first();
@@ -121,7 +98,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')
@@ -156,7 +133,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage');
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
// Get event
const event = await db('events')
.where({ slug })
.where('is_active', true)
.where('is_active', formatBoolean(true))
.first();
if (!event) {
+6 -5
View File
@@ -4,6 +4,7 @@ const { archiveEvent } = require('./archiveService');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatDate } = require('../utils/dateFormatter');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
@@ -21,8 +22,8 @@ async function checkExpirations() {
// Check for events needing warning emails
const eventsNeedingWarning = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -40,8 +41,8 @@ async function checkExpirations() {
// Check for expired events
const expiredEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
@@ -74,7 +75,7 @@ async function queueExpirationWarning(event) {
async function handleExpiredEvent(event) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: false });
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
+2 -1
View File
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const logger = require('../utils/logger');
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Find the event
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return;
// Get file stats
+26 -9
View File
@@ -2,6 +2,10 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
const THUMBNAIL_WIDTH = 300;
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
// Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
try {
// Generate thumbnail with memory-efficient settings
await sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true // More memory efficient for large images
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
console.error(`Failed to generate thumbnail for ${filename}:`, error);
// Return null if thumbnail generation fails, don't fail the whole upload
return null;
}
}
module.exports = { generateThumbnail };
+3 -2
View File
@@ -4,6 +4,7 @@
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger');
// Configuration constants
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '<', cutoffTime.toISOString())
.delete();
} catch (error) {
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
+15 -1
View File
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
try {
// Get date format setting from database
const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT;
let dateConfig = DEFAULT_FORMAT;
if (setting && setting.setting_value) {
// Handle both string and object values
if (typeof setting.setting_value === 'string') {
try {
dateConfig = JSON.parse(setting.setting_value);
} catch (e) {
console.warn('Failed to parse date format setting:', e.message);
dateConfig = DEFAULT_FORMAT;
}
} else {
dateConfig = setting.setting_value;
}
}
const dateObj = date instanceof Date ? date : new Date(date);
+133
View File
@@ -0,0 +1,133 @@
/**
* Database Compatibility Utilities
* Handles differences between PostgreSQL and SQLite
*/
// Note: Requiring db here creates circular dependency
// db should be passed as parameter or required where needed
/**
* Get database client type
* @returns {string} 'pg' or 'sqlite3'
*/
function getDbClient() {
return process.env.DATABASE_CLIENT || 'sqlite3';
}
/**
* Check if using PostgreSQL
* @returns {boolean}
*/
function isPostgreSQL() {
return getDbClient() === 'pg';
}
/**
* Handle insert operations that return IDs
* Works with both PostgreSQL and SQLite
* @param {object} query - Knex query builder
* @returns {Promise<number>} The inserted ID
*/
async function insertAndGetId(query) {
const result = await query.returning('id');
// PostgreSQL returns array of objects [{id: 1}]
// SQLite returns array of IDs [1]
return result[0]?.id || result[0];
}
/**
* Format date for database compatibility
* @param {Date} date - JavaScript Date object
* @returns {string} ISO string format that works on both databases
*/
function formatDateForDB(date) {
return date.toISOString();
}
/**
* Add days to a date (database agnostic)
* @param {Date} date - Starting date
* @param {number} days - Number of days to add
* @returns {Date} New date
*/
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Get date extraction SQL that works on both databases
* @param {object} db - Knex database instance
* @param {string} column - Column name
* @returns {object} Knex raw query
*/
function dateExtractSQL(db, column) {
if (isPostgreSQL()) {
return db.raw(`DATE(${column})`);
} else {
// SQLite uses date() function
return db.raw(`date(${column})`);
}
}
/**
* Get database size query
* @param {object} db - Knex database instance
* @param {string} dbName - Database name
* @returns {Promise<number>} Size in bytes
*/
async function getDatabaseSize(db, dbName) {
if (isPostgreSQL()) {
const result = await db.raw('SELECT pg_database_size(?) as size', [dbName]);
return result.rows[0]?.size || 0;
} else {
// For SQLite, check file size
const fs = require('fs').promises;
const path = require('path');
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../data/photo_sharing.db');
try {
const stats = await fs.stat(dbPath);
return stats.size;
} catch (error) {
console.error('Error getting SQLite database size:', error);
return 0;
}
}
}
/**
* Handle boolean values for database compatibility
* @param {boolean} value - Boolean value
* @returns {any} Database-appropriate boolean representation
*/
function formatBoolean(value) {
if (isPostgreSQL()) {
return value;
} else {
// SQLite stores booleans as 0/1
return value ? 1 : 0;
}
}
/**
* Parse boolean from database
* @param {any} value - Database boolean value
* @returns {boolean} JavaScript boolean
*/
function parseBoolean(value) {
return Boolean(value);
}
module.exports = {
getDbClient,
isPostgreSQL,
insertAndGetId,
formatDateForDB,
addDays,
dateExtractSQL,
getDatabaseSize,
formatBoolean,
parseBoolean
};
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: 412 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.26",
"version": "1.0.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.26",
"version": "1.0.33",
"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.26",
"version": "1.0.33",
"type": "module",
"scripts": {
"dev": "vite",
+85 -26
View File
@@ -18,6 +18,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -32,6 +34,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) {
const allowedNewFiles = 500 - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
return;
}
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
@@ -41,38 +57,64 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > 500) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
return;
}
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach((file, index) => {
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
formData.append('photos', file);
});
// For large uploads, chunk the files to prevent memory issues
const CHUNK_SIZE = 50; // Upload 50 files at a time
const chunks = [];
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
// Debug: Log FormData contents
console.log('FormData entries:');
for (let pair of formData.entries()) {
console.log(pair[0], pair[1]);
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
}
setTotalChunks(chunks.length);
let totalUploaded = 0;
let failedFiles = [];
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
// Don't set Content-Type header - axios will set it with the boundary
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(progress);
}
},
});
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
console.log('Upload result:', response.data);
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
}
},
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files
setSelectedFiles([]);
@@ -80,8 +122,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
fileInputRef.current.value = '';
}
// Show success message
toast.success(t('toast.uploadSuccess'));
// Show appropriate message
if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
);
}
// Call callback
if (onUploadComplete) {
@@ -93,6 +142,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} finally {
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
}
};
@@ -203,7 +254,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 mb-1">
<span>{t('upload.uploading')}</span>
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
@@ -212,6 +266,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 mt-1">
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
</p>
)}
</div>
)}
</div>
@@ -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>
+37 -14
View File
@@ -32,14 +32,24 @@ api.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// For gallery routes, get the slug from the URL path
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
}
@@ -73,21 +83,34 @@ api.interceptors.response.use(
}
if (error.response?.status === 401) {
// Redirect to appropriate login
// Check if it's an admin route
const isAdminRoute = error.config?.url?.includes('/admin');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
window.location.href = '/admin/login';
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login';
}
} else {
// For gallery routes, clear gallery-specific token and redirect
const currentPath = window.location.pathname;
const pathParts = currentPath.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
window.location.href = `/gallery/${gallerySlug}`;
// For gallery routes, check if the error is from a gallery API call
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
if (currentPath.startsWith('/gallery/')) {
// If we have a gallery match from the API URL, clear that specific gallery's token
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else {
// We're not on a gallery page but got a 401 from a gallery API
// This shouldn't happen in normal flow, but if it does, redirect to homepage
window.location.href = '/';
}
}
}
+2
View File
@@ -18,6 +18,8 @@ export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
// Add a small delay to ensure auth token is properly set
retryDelay: 100,
});
};
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen"
"uploadPhotos": "Fotos hochladen",
"maxFilesReached": "Maximal 500 Dateien erlaubt",
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden"
},
"navigation": {
"dashboard": "Dashboard",
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos"
"uploadPhotos": "Upload Photos",
"maxFilesReached": "Maximum 500 files allowed",
"someFilesSkipped": "Some files were skipped (500 file limit)",
"tooManyFiles": "Maximum 500 files can be uploaded at once"
},
"navigation": {
"dashboard": "Dashboard",